From 5b41a6aa9b9aa583bd57206954da5546730c8563 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sun, 6 Sep 2026 18:44:02 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E7=BB=9F=E4=B8=80APP=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E9=AA=A8=E6=9E=B6=20+=20=E5=AE=98=E6=96=B9=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=AE=A1=E7=BA=BFKotlin=E7=A7=BB=E6=A4=8D(=E4=B8=8EC?= =?UTF-8?q?=E5=8F=82=E8=80=83=E9=80=90=E5=AD=97=E8=8A=82=E4=B8=80=E8=87=B4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analysis/gen_kotlin_tables.py | 115 +++ analysis/render_offline.c | 69 ++ analysis/test_ref_stats.txt | 60 ++ android/.gitignore | 6 + android/app/build.gradle.kts | 52 ++ android/app/src/main/AndroidManifest.xml | 23 + .../com/mag160c/thermal/MainActivity.kt | 21 + .../com/mag160c/thermal/core/ByteReader.kt | 20 + .../com/mag160c/thermal/core/FrameStream.kt | 92 +++ .../mag160c/thermal/core/OfficialTables.kt | 214 ++++++ .../mag160c/thermal/core/RenderPipeline.kt | 686 ++++++++++++++++++ .../com/mag160c/thermal/core/TempMath.kt | 52 ++ .../thermal/core/RenderPipelineTest.kt | 105 +++ .../src/test/resources/mag160c_official.ddt | 3 + .../app/src/test/resources/test_frames.bin | 3 + android/app/src/test/resources/test_ref.rgb | 3 + .../app/src/test/resources/test_ref_stats.txt | 60 ++ android/build.gradle.kts | 5 + android/gradle.properties | 4 + android/gradle/libs.versions.toml | 26 + android/gradle/wrapper/gradle-wrapper.jar | 3 + .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 251 +++++++ android/gradlew.bat | 94 +++ android/settings.gradle.kts | 16 + docs/android_app/reverse_apk_features.md | 105 +++ docs/android_app/session_state.md | 89 +++ 27 files changed, 2184 insertions(+) create mode 100644 analysis/gen_kotlin_tables.py create mode 100644 analysis/render_offline.c create mode 100644 analysis/test_ref_stats.txt create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/core/ByteReader.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/core/FrameStream.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTables.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt create mode 100644 android/app/src/test/kotlin/com/mag160c/thermal/core/RenderPipelineTest.kt create mode 100644 android/app/src/test/resources/mag160c_official.ddt create mode 100644 android/app/src/test/resources/test_frames.bin create mode 100644 android/app/src/test/resources/test_ref.rgb create mode 100644 android/app/src/test/resources/test_ref_stats.txt create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 docs/android_app/reverse_apk_features.md create mode 100644 docs/android_app/session_state.md diff --git a/analysis/gen_kotlin_tables.py b/analysis/gen_kotlin_tables.py new file mode 100644 index 0000000..7433308 --- /dev/null +++ b/analysis/gen_kotlin_tables.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Generate Kotlin tables from csdk C headers (palette256, t2e).""" +import re, sys, io + +CSDK = r"C:\Project\MAG160C\csdk\src" +OUT = r"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt" + +def read(fn): + with open(fn, encoding="utf-8") as f: + return f.read() + +def parse_matrix(text, rows=256): + vals = [] + for m in re.finditer(r"\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}", text): + vals.append((int(m.group(3)), int(m.group(2)), int(m.group(1)))) # R,G,B from (B,G,R,0) + if len(vals) == rows: + break + return vals + +def parse_int32(text): + body = text[text.index("{"):] + return [int(x) for x in re.findall(r"-?\d+", body)] + +pal_txt = read(rf"{CSDK}\mag160c_official_palette256.h") +t2e_txt = read(rf"{CSDK}\mag160c_official_t2e.h") +tables_txt = read(rf"{CSDK}\mag160c_tables.h") + +pal = parse_matrix(pal_txt) +assert len(pal) == 256, f"palette entries: {len(pal)}" +t2e = parse_int32(t2e_txt) +assert len(t2e) == 646, f"t2e entries: {len(t2e)}" + +# mag160c_t2e (274 entries, hex) + mag160c_e2t_acc_q10 (274 entries) +def parse_hex_array(text, name): + m = re.search(rf"static const uint32_t {name}\[MAG160C_TEMP_CURVE_ENTRIES\] = \{{(.*?)\}};", text, re.S) + assert m, name + return [int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", m.group(1))] + +t2e274 = parse_hex_array(tables_txt, "mag160c_t2e") +assert len(t2e274) == 274, f"t2e274: {len(t2e274)}" +e2t = parse_hex_array(tables_txt, "mag160c_e2t_acc_q10") +assert len(e2t) == 274, f"e2t: {len(e2t)}" + +# palette ARGB int per gray index +lines = [] +lines.append("package com.mag160c.thermal.core") +lines.append("") +lines.append("/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */") +lines.append("object OfficialTables {") +lines.append(" /** Official default palette (256 gray levels -> ARGB int), source: CoreSDKLib dev+0xb18. */") +lines.append(" val PALETTE256_ARGB = intArrayBuilder {") +for i, (r, g, b) in enumerate(pal): + argb = (0xFF << 24) | (r << 16) | (g << 8) | b + lines.append(f" add({argb}) // {i}") +lines.append(" }") +lines.append("") +lines.append(" /** Official T2E table (646 int32), temp = slope*diff>>12 + (i<<12) - 0x249f0. */") +lines.append(" val T2E = intArrayBuilder {") +for i in range(0, len(t2e), 8): + chunk = t2e[i:i+8] + lines.append(" add(" + ", ".join(str(v) for v in chunk) + (")" if i + 8 >= len(t2e) else "")) +lines.append(" }") +lines.append("}") +kt = "\n".join(lines) + "\n" + +# intArrayBuilder trick is overkill; use direct arrays with chunked lines instead. +argb_lines = [] +def signed(v): + return v if v < 2**31 else v - 2**32 +for i in range(0, len(pal), 6): + row = ", ".join(str(signed((0xFF << 24) | (r << 16) | (g << 8) | b)) for (r, g, b) in pal[i:i+6]) + argb_lines.append(" " + row + ",") +t2e_lines = [] +for i in range(0, len(t2e), 8): + row = ", ".join(str(v) for v in t2e[i:i+8]) + t2e_lines.append(" " + row + ("," if i + 8 < len(t2e) else "")) + +t2e274_lines = [] +for i in range(0, len(t2e274), 8): + row = ", ".join(f"0x{v:08x}" for v in t2e274[i:i+8]) + t2e274_lines.append(" " + row + ("," if i + 8 < len(t2e274) else "")) + +e2t_lines = [] +for i in range(0, len(e2t), 8): + row = ", ".join(f"0x{v:08x}" for v in e2t[i:i+8]) + e2t_lines.append(" " + row + ("," if i + 8 < len(e2t) else "")) + +kt = f"""package com.mag160c.thermal.core + +/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */ +object OfficialTables {{ + /** Official default palette: 256 gray levels -> ARGB int (from CoreSDKLib dev+0xb18, B,G,R order swapped). */ + val PALETTE256_ARGB = intArrayOf( +{chr(10).join(argb_lines)} + ) + + /** Official T2E table (646 int32): temp = slope*diff>>12 + (i<<12) - 0x249f0. */ + val T2E = intArrayOf( +{chr(10).join(t2e_lines)} + ) + + /** Vendor T2E curve from libcoresdk.so ARM64 @0x402010 (274 uint32 entries). */ + val T2E274 = intArrayOf( +{chr(10).join(t2e274_lines)} + ) + + /** Vendor E2TAccQ10 curve from libcoresdk.so ARM64 @0x40245c (274 uint32 entries). */ + val E2T_ACC_Q10 = intArrayOf( +{chr(10).join(e2t_lines)} + ) +}} +""" +with open(OUT, "w", encoding="utf-8", newline="\n") as f: + f.write(kt) +print(f"written {OUT}: palette {len(pal)} entries, t2e {len(t2e)} entries") diff --git a/analysis/render_offline.c b/analysis/render_offline.c new file mode 100644 index 0000000..a2b4a10 --- /dev/null +++ b/analysis/render_offline.c @@ -0,0 +1,69 @@ +/* render_offline.c - PC reference runner for the official mag160c pipeline. + * Usage: render_offline.exe + * frames.bin = sequence of complete USB frames (0x38 + 38400 bytes each). + * Writes per-frame 320x240x3 RGB24 + one stats line per frame. + */ +#include +#include +#include +#include "mag160c/mag160c_render.h" + +static unsigned rd32(const unsigned char *p) { + return p[0] | (p[1] << 8) | (p[2] << 16) | ((unsigned)p[3] << 24); +} + +static void ffc_cb(void *user, int param) { (void)user; (void)param; } + +int main(int argc, char **argv) { + if (argc < 5) { fprintf(stderr, "usage: %s ddt frames out.rgb stats\n", argv[0]); return 1; } + FILE *ff = fopen(argv[2], "rb"); + if (!ff) { fprintf(stderr, "open frames fail\n"); return 1; } + fseek(ff, 0, SEEK_END); + long fsz = ftell(ff); + fseek(ff, 0, SEEK_SET); + unsigned char *buf = malloc(fsz); + if (fread(buf, 1, fsz, ff) != (size_t)fsz) { fclose(ff); return 1; } + fclose(ff); + + const unsigned frame_len = 38400; + const unsigned total = 0x38 + frame_len; + if (fsz < (long)total) { fprintf(stderr, "frames too small\n"); return 1; } + + FILE *out = fopen(argv[3], "wb"); + FILE *st = fopen(argv[4], "w"); + if (!out || !st) { fprintf(stderr, "open out fail\n"); return 1; } + + mag160c_render_t *r = NULL; + mag160c_render_cfg_t cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.width = 160; cfg.height = 120; + cfg.ddt_path = argv[1]; + cfg.ffc_cb = ffc_cb; + cfg.startup_force_ffc = 1; + cfg.cdf_pivot_75 = 1; + if (mag160c_render_init(&r, &cfg) != MAG160C_OK) { + fprintf(stderr, "render init fail\n"); + return 1; + } + + unsigned char *rgb = malloc(320 * 240 * 3); + int nframes = fsz / total; + for (int f = 0; f < nframes; ++f) { + unsigned char *fr = buf + (long)f * total; + /* sanity: marker + len */ + if (rd32(fr) != 0x1bb1b11b) { fprintf(stderr, "bad marker at frame %d\n", f); return 1; } + if (rd32(fr + 8) != frame_len) { fprintf(stderr, "bad len at frame %d\n", f); return 1; } + if (rd32(fr + 0x1c + frame_len) != 0x1bb1b11c) { fprintf(stderr, "bad trailer at frame %d\n", f); return 1; } + mag160c_error_t rc = mag160c_render_frame(r, fr, 1, rgb); + if (rc == MAG160C_OK) { + fwrite(rgb, 1, 320 * 240 * 3, out); + int lo, hi, mn, mx, mean, sd; + mag160c_render_get_stats(r, &lo, &hi, &mn, &mx, &mean, &sd); + fprintf(st, "%d %d %d %d %d %d %d\n", f, lo, hi, mn, mx, mean, sd); + } else { + fprintf(st, "%d HIDDEN\n", f); + } + } + fclose(out); fclose(st); + return 0; +} diff --git a/analysis/test_ref_stats.txt b/analysis/test_ref_stats.txt new file mode 100644 index 0000000..61ee250 --- /dev/null +++ b/analysis/test_ref_stats.txt @@ -0,0 +1,60 @@ +0 HIDDEN +1 HIDDEN +2 HIDDEN +3 HIDDEN +4 HIDDEN +5 HIDDEN +6 HIDDEN +7 HIDDEN +8 HIDDEN +9 HIDDEN +10 HIDDEN +11 HIDDEN +12 HIDDEN +13 HIDDEN +14 HIDDEN +15 HIDDEN +16 HIDDEN +17 HIDDEN +18 HIDDEN +19 HIDDEN +20 HIDDEN +21 HIDDEN +22 HIDDEN +23 HIDDEN +24 HIDDEN +25 HIDDEN +26 HIDDEN +27 HIDDEN +28 HIDDEN +29 HIDDEN +30 HIDDEN +31 HIDDEN +32 6961 7961 7118 7668 7461 103 +33 6962 7962 7098 7663 7462 103 +34 6962 7962 7116 7675 7462 103 +35 6961 7961 7106 7672 7461 103 +36 6961 7961 7106 7659 7461 103 +37 6961 7961 7116 7664 7461 103 +38 6961 7961 7121 7671 7461 103 +39 6962 7962 7117 7673 7462 103 +40 6962 7962 7132 7667 7462 103 +41 6962 7962 7105 7664 7462 103 +42 6961 7961 7118 7671 7461 103 +43 6961 7961 7130 7671 7461 103 +44 6962 7962 7122 7661 7462 103 +45 6961 7961 7096 7669 7461 103 +46 6961 7961 7096 7671 7461 103 +47 6962 7962 7122 7675 7462 103 +48 6961 7961 7094 7667 7461 103 +49 6962 7962 7099 7664 7462 103 +50 6962 7962 7134 7669 7462 103 +51 6962 7962 7133 7662 7462 103 +52 6961 7961 7129 7671 7461 103 +53 6962 7962 7126 7671 7462 103 +54 6961 7961 7119 7669 7461 103 +55 6961 7961 7096 7673 7461 103 +56 6961 7961 7135 7661 7461 103 +57 6962 7962 7099 7662 7462 103 +58 6961 7961 7122 7659 7461 103 +59 6962 7962 7116 7669 7462 103 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..82426b1 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +captures/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..505efb2 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.mag160c.thermal" + compileSdk = 36 + + defaultConfig { + applicationId = "com.mag160c.thermal" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.activity.compose) + implementation(libs.core.ktx) + implementation(libs.lifecycle.runtime.ktx) + debugImplementation(libs.compose.ui.tooling) + testImplementation(libs.junit) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9bc67b1 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt new file mode 100644 index 0000000..888d0ab --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt @@ -0,0 +1,21 @@ +package com.mag160c.thermal + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { App() } + } +} + +@Composable +private fun App() { + Text("MAG160C build check") +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/ByteReader.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/ByteReader.kt new file mode 100644 index 0000000..26c52fb --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/ByteReader.kt @@ -0,0 +1,20 @@ +package com.mag160c.thermal.core + +/** Little-endian byte helpers used by the recovered vendor parsers. */ +object ByteReader { + fun rd32(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or + ((b[off + 1].toInt() and 0xFF) shl 8) or + ((b[off + 2].toInt() and 0xFF) shl 16) or + ((b[off + 3].toInt() and 0xFF) shl 24) + + fun rd16(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8) + + /** Decode `count` little-endian uint16 values into an IntArray. */ + fun decodeU16(b: ByteArray, off: Int, count: Int, out: IntArray, outOff: Int = 0) { + for (i in 0 until count) { + out[outOff + i] = rd16(b, off + i * 2) + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/FrameStream.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/FrameStream.kt new file mode 100644 index 0000000..bd4e394 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/FrameStream.kt @@ -0,0 +1,92 @@ +package com.mag160c.thermal.core + +/** + * Streaming frame assembler, ported from csdk/src/mag160c_frame.c (vendor + * libmagcore reader thread). Feed every bulk read from EP 0x81 into + * [push]; when a complete valid frame is buffered it is copied to + * [outFrame] and the returned length is > 0. + */ +class FrameStream(maxFrameLen: Int) { + private val cap = maxFrameLen * 2 + 0x470 + private val buf = ByteArray(cap) + private var len = 0 + private var aligned = false + + /** + * Append bulk-read data. Returns the total frame size when a complete + * frame has been copied into [outFrame] (capacity must be >= 0x38 + len), + * otherwise 0. + */ + fun push(data: ByteArray, size: Int, outFrame: ByteArray): Int { + if (cap == 0 || size > cap - len) { + len = 0 + aligned = false + } + if (size > cap - len) { + len = 0 + } + System.arraycopy(data, 0, buf, len, size) + len += size + + if (!aligned) { + alignToMarker() + if (!aligned) return 0 + } + + if (len < FRAME_OVERHEAD) return 0 + val dataLen = ByteReader.rd32(buf, 8) + if (dataLen < 0 || dataLen > cap - FRAME_OVERHEAD) { + len = 0 // bogus length; resync + return 0 + } + if (len < FRAME_OVERHEAD + dataLen) return 0 + + if (!isValidFrame(buf, len)) { + len = 0 + aligned = false + return 0 + } + val total = FRAME_OVERHEAD + dataLen + System.arraycopy(buf, 0, outFrame, 0, total) + + // consume frame + System.arraycopy(buf, total, buf, 0, len - total) + len -= total + if (len == 0) aligned = false + return total + } + + private fun alignToMarker() { + var i = 0 + while (i + 4 <= len) { + if (ByteReader.rd32(buf, i) == FRAME_MARKER) break + i += 4 + } + if (i + 4 > len) { + len = 0 + return + } + if (i != 0) { + System.arraycopy(buf, i, buf, 0, len - i) + len -= i + } + aligned = true + } + + companion object { + const val FRAME_MARKER = 0x1BB1B11B + const val FRAME_TRAILING_MARKER = 0x1BB1B11C + const val DATA_OFFSET = 0x1C + const val FRAME_OVERHEAD = 0x38 + + /** Validate a buffered frame: marker, type <= 1, length and trailer. */ + fun isValidFrame(buf: ByteArray, size: Int): Boolean { + if (size < DATA_OFFSET + 4) return false + if (ByteReader.rd32(buf, 0) != FRAME_MARKER) return false + if (ByteReader.rd32(buf, 12) > 1) return false + val dataLen = ByteReader.rd32(buf, 8) + if (size < FRAME_OVERHEAD + dataLen) return false + return ByteReader.rd32(buf, DATA_OFFSET + dataLen) == FRAME_TRAILING_MARKER + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTables.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTables.kt new file mode 100644 index 0000000..cb8cd82 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTables.kt @@ -0,0 +1,214 @@ +package com.mag160c.thermal.core + +/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */ +object OfficialTables { + /** Official default palette: 256 gray levels -> ARGB int (from CoreSDKLib dev+0xb18, B,G,R order swapped). */ + val PALETTE256_ARGB = intArrayOf( + -16777216, -16777211, -16777206, -16777201, -16777195, -16777190, + -16777185, -16777179, -16777174, -16777169, -16777163, -16777158, + -16777153, -16777147, -16777142, -16777137, -16777131, -16777126, + -16777121, -16777115, -16777110, -16777105, -16777099, -16646027, + -16449418, -16318346, -16121737, -15925129, -15794056, -15597448, + -15400839, -15269767, -15073158, -14876550, -14745477, -14548869, + -14352260, -14221188, -14024579, -13827971, -13696898, -13500290, + -13369217, -13172609, -12976000, -12844928, -12648319, -12451710, + -12320638, -12124029, -11927421, -11796348, -11599740, -11403131, + -11272059, -11075450, -10878842, -10747769, -10551161, -10420088, + -10223480, -10026871, -9895799, -9699190, -9502582, -9371509, + -9174901, -8978292, -8847220, -8650611, -8454002, -8322930, + -8126321, -7929713, -7798640, -7602032, -7470959, -7274351, + -7077742, -6946670, -6750061, -6553453, -6422380, -6225772, + -6029163, -5898091, -5701482, -5504874, -5373801, -5177193, + -4980584, -4849512, -4652903, -4456294, -4390504, -4324458, + -4192876, -4127086, -4061040, -3929458, -3863412, -3797622, + -3666040, -3599994, -3534204, -3402622, -3336576, -3270530, + -3139204, -3073158, -3007112, -2875530, -2809740, -2743694, + -2612112, -2546322, -2480276, -2348694, -2282648, -2216858, + -2085276, -2019230, -1887905, -1887397, -1821353, -1755310, + -1689522, -1623479, -1557435, -1491392, -1425604, -1359561, + -1293517, -1227729, -1161686, -1095642, -1029599, -963811, + -963304, -897260, -831473, -765425, -699377, -633329, + -567537, -501489, -435441, -369393, -303601, -237553, + -171505, -105713, -39665, -39153, -38641, -38385, + -37873, -37361, -37105, -36593, -36081, -35569, + -35313, -34801, -34289, -33777, -33521, -33009, + -32497, -32241, -31729, -31217, -30705, -30449, + -29937, -29425, -28913, -28657, -28145, -27633, + -27377, -26865, -26353, -25841, -25585, -25073, + -24561, -24305, -23793, -23281, -22769, -22513, + -22001, -21489, -20977, -20721, -20209, -19697, + -19441, -18929, -18417, -17905, -17649, -17137, + -16625, -16369, -15857, -15345, -14833, -14577, + -14065, -13553, -13041, -12785, -12273, -11761, + -11505, -10993, -10481, -9969, -9713, -9201, + -8689, -8433, -7914, -7394, -6875, -6611, + -6092, -5572, -5053, -4789, -4270, -3750, + -3487, -2967, -2448, -1928, -1665, -1145, + -626, -106, -99, -91, -84, -76, + -69, -61, -54, -46, -39, -31, + -24, -16, -9, -1, + ) + + /** Official T2E table (646 int32): temp = slope*diff>>12 + (i<<12) - 0x249f0. */ + val T2E = intArrayOf( + 51, 70, 94, 125, 162, 208, 264, 331, + 410, 503, 612, 737, 881, 1045, 1231, 1440, + 1675, 1937, 2227, 2547, 2899, 3285, 3705, 4162, + 4658, 5192, 5768, 6386, 7047, 7754, 8506, 9305, + 10153, 11050, 11997, 12995, 14045, 15147, 16303, 17513, + 18778, 20098, 21473, 22905, 24393, 25938, 27540, 29199, + 30916, 32690, 34522, 36412, 38360, 40366, 42430, 44552, + 46731, 48968, 51262, 53613, 56022, 58487, 61008, 63586, + 66220, 68910, 71654, 74454, 77308, 80217, 83179, 86195, + 89263, 92385, 95558, 98783, 102059, 105387, 108764, 112191, + 115668, 119194, 122768, 126391, 130060, 133777, 137541, 141350, + 145205, 149105, 153050, 157039, 161072, 165147, 169266, 173426, + 177629, 181873, 186157, 190482, 194847, 199251, 203694, 208175, + 212695, 217252, 221847, 226478, 231145, 235849, 240587, 245361, + 250169, 255012, 259888, 264797, 269740, 274715, 279722, 284761, + 289831, 294933, 300064, 305227, 310419, 315640, 320891, 326170, + 331478, 336814, 342178, 347569, 352988, 358433, 363904, 369402, + 374925, 380474, 386048, 391647, 397271, 402919, 408591, 414286, + 420005, 425747, 431512, 437300, 443109, 448941, 454795, 460670, + 466566, 472484, 478422, 484381, 490360, 496359, 502378, 508416, + 514474, 520551, 526647, 532761, 538894, 545045, 551215, 557402, + 563606, 569828, 576068, 582324, 588597, 594887, 601193, 607516, + 613854, 620209, 626579, 632965, 639366, 645782, 652213, 658660, + 665120, 671596, 678086, 684589, 691108, 697639, 704185, 710744, + 717317, 723903, 730502, 737114, 743740, 750377, 757028, 763691, + 770366, 777053, 783753, 790464, 797187, 803922, 810669, 817427, + 824196, 830977, 837768, 844571, 851384, 858209, 865043, 871889, + 878745, 885611, 892487, 899374, 906270, 913177, 920093, 927019, + 933954, 940899, 947854, 954818, 961791, 968773, 975764, 982764, + 989773, 996791, 1003818, 1010853, 1017897, 1024949, 1032009, 1039078, + 1046155, 1053240, 1060333, 1067435, 1074544, 1081661, 1088785, 1095918, + 1103058, 1110205, 1117360, 1124523, 1131692, 1138869, 1146053, 1153245, + 1160443, 1167648, 1174860, 1182080, 1189305, 1196538, 1203777, 1211023, + 1218276, 1225535, 1232800, 1240072, 1247350, 1254635, 1261925, 1269222, + 1276525, 1283834, 1291149, 1298470, 1305797, 1313129, 1320468, 1327812, + 1335162, 1342517, 1349878, 1357245, 1364617, 1371995, 1379378, 1386766, + 1394160, 1401559, 1408963, 1416372, 1423786, 1431206, 1438631, 1446060, + 1453495, 1460934, 1468379, 1475828, 1483282, 1490741, 1498204, 1505672, + 1513145, 1520622, 1528104, 1535591, 1543082, 1550578, 1558077, 1565582, + 1573090, 1580603, 1588121, 1595642, 1603168, 1610698, 1618232, 1625770, + 1633312, 1640859, 1648409, 1655963, 1663522, 1671084, 1678650, 1686220, + 1693794, 1701371, 1708953, 1716538, 1724127, 1731719, 1739315, 1746915, + 1754519, 1762126, 1769736, 1777350, 1784968, 1792589, 1800214, 1807842, + 1815473, 1823108, 1830746, 1838387, 1846032, 1853680, 1861331, 1868986, + 1876643, 1884304, 1891968, 1899635, 1907306, 1914979, 1922655, 1930335, + 1938017, 1945703, 1953391, 1961082, 1968777, 1976474, 1984174, 1991877, + 1999583, 2007292, 2015003, 2022718, 2030435, 2038155, 2045877, 2053602, + 2061330, 2069061, 2076794, 2084530, 2092269, 2100010, 2107754, 2115500, + 2123249, 2131001, 2138755, 2146511, 2154270, 2162031, 2169795, 2177562, + 2185330, 2193101, 2200875, 2208651, 2216429, 2224210, 2231993, 2239778, + 2247565, 2255355, 2263147, 2270941, 2278738, 2286537, 2294338, 2302141, + 2309946, 2317754, 2325563, 2333375, 2341189, 2349005, 2356823, 2364643, + 2372465, 2380290, 2388116, 2395944, 2403775, 2411607, 2419441, 2427278, + 2435116, 2442956, 2450798, 2458642, 2466488, 2474336, 2482186, 2490038, + 2497891, 2505747, 2513604, 2521463, 2529324, 2537187, 2545051, 2552918, + 2560786, 2568656, 2576527, 2584401, 2592276, 2600153, 2608031, 2615911, + 2623793, 2631677, 2639562, 2647449, 2655338, 2663228, 2671120, 2679014, + 2686909, 2694806, 2702704, 2710604, 2718505, 2726408, 2734313, 2742219, + 2750127, 2758036, 2765947, 2773859, 2781773, 2789688, 2797605, 2805524, + 2813443, 2821364, 2829287, 2837211, 2845137, 2853064, 2860992, 2868922, + 2876853, 2884786, 2892720, 2900655, 2908592, 2916530, 2924470, 2932411, + 2940353, 2948296, 2956241, 2964187, 2972135, 2980084, 2988034, 2995985, + 3003938, 3011892, 3019847, 3027803, 3035761, 3043720, 3051680, 3059642, + 3067605, 3075569, 3083534, 3091500, 3099468, 3107436, 3115406, 3123378, + 3131350, 3139323, 3147298, 3155274, 3163251, 3171229, 3179208, 3187189, + 3195170, 3203153, 3211137, 3219121, 3227107, 3235095, 3243083, 3251072, + 3259062, 3267054, 3275046, 3283040, 3291035, 3299030, 3307027, 3315025, + 3323024, 3331023, 3339024, 3347026, 3355029, 3363033, 3371038, 3379044, + 3387051, 3395059, 3403068, 3411078, 3419089, 3427101, 3435113, 3443127, + 3451142, 3459158, 3467174, 3475192, 3483211, 3491230, 3499250, 3507272, + 3515294, 3523317, 3531341, 3539366, 3547392, 3555419, 3563447, 3571475, + 3579505, 3587535, 3595566, 3603598, 3611631, 3619665, 3627700, 3635735, + 3643772, 3651809, 3659847, 3667886, 3675926, 3683966, 3692008, 3700050, + 3708093, 3716137, 3724182, 3732227, 3740273, 3748321, 3756368, 3764417, + 3772467, 3780517, 3788568, 3796620, 3804672, 3812726, 3820780, 3828835, + 3836891, 3844947, 3853004, 3861062, 3869121, 3877180, 3885240, 3893301, + 3901363, 3909425, 3917488, 3925552, 3933617, 3941682, 3949748, 3957814, + 3965882, 3973950, 3982019, 3990088, 3998158, 4006229, 4014301, 4022373, + 4030446, 4038519, 4046594, 4054669, 4062744, 4070820, 4078897, 4086975, + 4095053, 4103132, 4111212, 4119292, 4127373, 4135454, 4143536, 4151619, + 4159703, 4167787, 4175871, 4183957, 4192042, 4200129 + ) + + /** Vendor T2E curve from libcoresdk.so ARM64 @0x402010 (274 uint32 entries). */ + val T2E274 = intArrayOf( + 0x000003e8, 0x000004d3, 0x000005e1, 0x00000714, 0x0000086e, 0x000009f1, 0x00000b9e, 0x00000d76, + 0x00000f7b, 0x000011ae, 0x0000140e, 0x0000169d, 0x0000195c, 0x00001c49, 0x00001f67, 0x000022b3, + 0x0000262f, 0x000029db, 0x00002db5, 0x000031bd, 0x000035f4, 0x00003a58, 0x00003ee9, 0x000043a7, + 0x00004890, 0x00004da4, 0x000052e2, 0x0000584a, 0x00005ddb, 0x00006394, 0x00006974, 0x00006f7a, + 0x000075a6, 0x00007bf7, 0x0000826c, 0x00008904, 0x00008fbf, 0x0000969b, 0x00009d98, 0x0000a4b6, + 0x0000abf3, 0x0000b34e, 0x0000bac8, 0x0000c25f, 0x0000ca12, 0x0000d1e2, 0x0000d9cc, 0x0000e1d1, + 0x0000e9f0, 0x0000f229, 0x0000fa7a, 0x000102e3, 0x00010b64, 0x000113fb, 0x00011ca9, 0x0001256d, + 0x00012e46, 0x00013734, 0x00014036, 0x0001494c, 0x00015276, 0x00015bb2, 0x00016501, 0x00016e62, + 0x000177d4, 0x00018158, 0x00018aec, 0x00019490, 0x00019e45, 0x0001a809, 0x0001b1dd, 0x0001bbbf, + 0x0001c5b0, 0x0001cfaf, 0x0001d9bc, 0x0001e3d7, 0x0001edff, 0x0001f833, 0x00020275, 0x00020cc3, + 0x0002171d, 0x00022183, 0x00022bf5, 0x00023672, 0x000240fa, 0x00024b8c, 0x0002562a, 0x000260d2, + 0x00026b84, 0x00027640, 0x00028106, 0x00028bd5, 0x000296ae, 0x0002a190, 0x0002ac7b, 0x0002b76f, + 0x0002c26b, 0x0002cd70, 0x0002d87d, 0x0002e392, 0x0002eeaf, 0x0002f9d4, 0x00030500, 0x00031034, + 0x00031b6f, 0x000326b1, 0x000331fb, 0x00033d4b, 0x000348a2, 0x00035400, 0x00035f64, 0x00036acf, + 0x00037640, 0x000381b7, 0x00038d34, 0x000398b7, 0x0003a440, 0x0003afcf, 0x0003bb63, 0x0003c6fd, + 0x0003d29c, 0x0003de40, 0x0003e9ea, 0x0003f599, 0x0004014d, 0x00040d05, 0x000418c3, 0x00042486, + 0x0004304d, 0x00043c18, 0x000447e9, 0x000453bd, 0x00045f96, 0x00046b74, 0x00047756, 0x0004833b, + 0x00048f25, 0x00049b13, 0x0004a705, 0x0004b2fb, 0x0004bef4, 0x0004caf2, 0x0004d6f3, 0x0004e2f8, + 0x0004ef00, 0x0004fb0c, 0x0005071b, 0x0005132e, 0x00051f44, 0x00052b5d, 0x0005377a, 0x0005439a, + 0x00054fbd, 0x00055be3, 0x0005680c, 0x00057439, 0x00058068, 0x00058c9a, 0x000598cf, 0x0005a507, + 0x0005b142, 0x0005bd7f, 0x0005c9bf, 0x0005d602, 0x0005e247, 0x0005ee90, 0x0005fada, 0x00060727, + 0x00061377, 0x00061fc9, 0x00062c1e, 0x00063875, 0x000644ce, 0x00065129, 0x00065d87, 0x000669e7, + 0x0006764a, 0x000682ae, 0x00068f15, 0x00069b7e, 0x0006a7e9, 0x0006b456, 0x0006c0c5, 0x0006cd36, + 0x0006d9a9, 0x0006e61e, 0x0006f295, 0x0006ff0e, 0x00070b89, 0x00071805, 0x00072484, 0x00073104, + 0x00073d86, 0x00074a0a, 0x0007568f, 0x00076317, 0x00076fa0, 0x00077c2a, 0x000788b7, 0x00079545, + 0x0007a1d4, 0x0007ae66, 0x0007baf8, 0x0007c78d, 0x0007d423, 0x0007e0ba, 0x0007ed53, 0x0007f9ed, + 0x00080689, 0x00081327, 0x00081fc5, 0x00082c65, 0x00083907, 0x000845aa, 0x0008524e, 0x00085ef4, + 0x00086b9b, 0x00087843, 0x000884ed, 0x00089197, 0x00089e44, 0x0008aaf1, 0x0008b7a0, 0x0008c44f, + 0x0008d100, 0x0008ddb3, 0x0008ea66, 0x0008f71b, 0x000903d0, 0x00091087, 0x00091d3f, 0x000929f9, + 0x000936b3, 0x0009436e, 0x0009502b, 0x00095ce8, 0x000969a7, 0x00097666, 0x00098327, 0x00098fe9, + 0x00099cab, 0x0009a96f, 0x0009b634, 0x0009c2f9, 0x0009cfc0, 0x0009dc88, 0x0009e950, 0x0009f61a, + 0x000a02e4, 0x000a0faf, 0x000a1c7c, 0x000a2949, 0x000a3617, 0x000a42e6, 0x000a4fb5, 0x000a5c86, + 0x000a6958, 0x000a762a, 0x000a82fd, 0x000a8fd1, 0x000a9ca6, 0x000aa97c, 0x000ab652, 0x000ac329, + 0x000ad001, 0x000adcda, 0x000ae9b4, 0x000af68e, 0x000b0369, 0x000b1045, 0x000b1d22, 0x000b29ff, + 0x000b36dd, 0x000b43bc + ) + + /** Vendor E2TAccQ10 curve from libcoresdk.so ARM64 @0x40245c (274 uint32 entries). */ + val E2T_ACC_Q10 = intArrayOf( + 0x00008b70, 0x0000795d, 0x00006abc, 0x00005eb5, 0x000054ac, 0x00004c62, 0x0000456c, 0x00003f62, + 0x00003a34, 0x000035e5, 0x00003207, 0x00002e9d, 0x00002bc0, 0x00002910, 0x000026d3, 0x000024bc, + 0x000022dc, 0x0000213c, 0x00001fc0, 0x00001e5e, 0x00001d27, 0x00001c08, 0x00001afe, 0x00001a12, + 0x00001935, 0x0000186b, 0x000017ad, 0x000016ff, 0x0000165e, 0x000015ca, 0x00001540, 0x000014bd, + 0x00001444, 0x000013d3, 0x0000136a, 0x00001305, 0x000012a9, 0x00001251, 0x000011fc, 0x000011af, + 0x00001167, 0x0000111f, 0x000010dd, 0x000010a0, 0x00001062, 0x0000102c, 0x00000ff6, 0x00000fc3, + 0x00000f91, 0x00000f64, 0x00000f38, 0x00000f0d, 0x00000ee7, 0x00000ebf, 0x00000e9a, 0x00000e78, + 0x00000e56, 0x00000e36, 0x00000e16, 0x00000df8, 0x00000ddc, 0x00000dc0, 0x00000da6, 0x00000d8d, + 0x00000d74, 0x00000d5d, 0x00000d47, 0x00000d30, 0x00000d1b, 0x00000d06, 0x00000cf4, 0x00000ce0, + 0x00000cce, 0x00000cbc, 0x00000cab, 0x00000c9a, 0x00000c8c, 0x00000c7a, 0x00000c6c, 0x00000c5e, + 0x00000c4f, 0x00000c41, 0x00000c34, 0x00000c28, 0x00000c1c, 0x00000c0e, 0x00000c03, 0x00000bf8, + 0x00000bed, 0x00000be2, 0x00000bd8, 0x00000bcd, 0x00000bc3, 0x00000bb9, 0x00000bb0, 0x00000ba7, + 0x00000b9e, 0x00000b95, 0x00000b8d, 0x00000b85, 0x00000b7c, 0x00000b75, 0x00000b6d, 0x00000b66, + 0x00000b5f, 0x00000b57, 0x00000b51, 0x00000b4a, 0x00000b43, 0x00000b3d, 0x00000b36, 0x00000b30, + 0x00000b2a, 0x00000b24, 0x00000b1e, 0x00000b19, 0x00000b13, 0x00000b0e, 0x00000b08, 0x00000b04, + 0x00000aff, 0x00000af9, 0x00000af5, 0x00000af0, 0x00000aec, 0x00000ae7, 0x00000ae2, 0x00000ade, + 0x00000adb, 0x00000ad5, 0x00000ad2, 0x00000ace, 0x00000ac9, 0x00000ac6, 0x00000ac3, 0x00000abe, + 0x00000abb, 0x00000ab7, 0x00000ab4, 0x00000ab1, 0x00000aac, 0x00000aaa, 0x00000aa6, 0x00000aa4, + 0x00000aa0, 0x00000a9d, 0x00000a9a, 0x00000a97, 0x00000a95, 0x00000a91, 0x00000a8f, 0x00000a8c, + 0x00000a89, 0x00000a87, 0x00000a83, 0x00000a82, 0x00000a7f, 0x00000a7c, 0x00000a7a, 0x00000a77, + 0x00000a76, 0x00000a73, 0x00000a70, 0x00000a6f, 0x00000a6b, 0x00000a6a, 0x00000a68, 0x00000a65, + 0x00000a64, 0x00000a61, 0x00000a5f, 0x00000a5e, 0x00000a5c, 0x00000a5a, 0x00000a58, 0x00000a55, + 0x00000a55, 0x00000a52, 0x00000a50, 0x00000a4f, 0x00000a4d, 0x00000a4b, 0x00000a4a, 0x00000a48, + 0x00000a46, 0x00000a45, 0x00000a43, 0x00000a42, 0x00000a41, 0x00000a3e, 0x00000a3d, 0x00000a3c, + 0x00000a3a, 0x00000a39, 0x00000a37, 0x00000a36, 0x00000a35, 0x00000a33, 0x00000a32, 0x00000a31, + 0x00000a2f, 0x00000a2f, 0x00000a2c, 0x00000a2c, 0x00000a2b, 0x00000a29, 0x00000a28, 0x00000a27, + 0x00000a25, 0x00000a25, 0x00000a23, 0x00000a22, 0x00000a21, 0x00000a20, 0x00000a1f, 0x00000a1e, + 0x00000a1d, 0x00000a1b, 0x00000a1b, 0x00000a19, 0x00000a19, 0x00000a17, 0x00000a17, 0x00000a16, + 0x00000a14, 0x00000a14, 0x00000a13, 0x00000a13, 0x00000a11, 0x00000a10, 0x00000a0f, 0x00000a0f, + 0x00000a0e, 0x00000a0c, 0x00000a0c, 0x00000a0b, 0x00000a0b, 0x00000a09, 0x00000a08, 0x00000a08, + 0x00000a07, 0x00000a06, 0x00000a06, 0x00000a05, 0x00000a04, 0x00000a04, 0x00000a02, 0x00000a02, + 0x00000a01, 0x00000a00, 0x00000a00, 0x000009ff, 0x000009fe, 0x000009fe, 0x000009fd, 0x000009fc, + 0x000009fc, 0x000009fb, 0x000009fa, 0x000009fa, 0x000009f9, 0x000009f9, 0x000009f8, 0x000009f7, + 0x000009f7, 0x000009f6, 0x000009f6, 0x000009f5, 0x000009f4, 0x000009f3, 0x000009f3, 0x000009f3, + 0x000009f2, 0x000009f1 + ) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt new file mode 100644 index 0000000..428a0e8 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt @@ -0,0 +1,686 @@ +package com.mag160c.thermal.core + +import kotlin.math.sqrt + +/** + * Official MAG160C rendering pipeline, ported from csdk/src/mag160c_render.c + * (pixel-verified against CoreSDKLib.dll). + * + * raw frame -> shutter extract -> FFC state machine -> endpoint select + * + Q12 table interp -> ref (4x type-1 mean) -> NUC lookup -> blind + * compensation -> stats/window -> LUT1024 rebuild -> gray -> 2x upscale + * -> palette -> ARGB output. + * + * Feed USB frames; get a 320x240 ARGB IntArray ready for Bitmap.setPixels. + * FFC commands are emitted through [onFfc] so the caller owns the transport. + */ +class RenderPipeline( + private val w: Int = 160, + private val h: Int = 120, + private val ffcPeriod: Int = 1800, + private val ffcDrift: Int = 250, + private val warmFrames: Int = 20, + private val cdfPivot75: Boolean = true, + private val force75: Boolean = true, + /** FFC command callback: param 0 = FFC(0), 1 = FFC(1). */ + private val onFfc: ((param: Int) -> Unit)? = null, +) { + private val npix = w * h + + // ---- DDT state ---- + private var epCount = 0 + private var nsegs = 0 + private var c5c = 0 + private var c60 = 0 + private var dev24 = 5 + private var dev4cShift = 3 + private var dev48 = 0 + private val epTemps = IntArray(64) + private val blindCnt = IntArray(64) + private val epThr = arrayOfNulls(64) + private val epGain = arrayOfNulls(64) + private val epBlind = arrayOfNulls(64) + private var sel = 0 + private var thrWork = ShortArray(0) + private var gainWork = IntArray(0) + private var blindWork: IntArray? = null + private var blindCount = 0 + + // ---- frame/FFC state ---- + private var ffcIdx = -1 + private var globalFrames = 0 + private var shutter = 0 + private var shutterAtFfc = 0 + private var manualFfc = false + private var hasRef = false + private var refCnt = 0 + private var startupFfcDone = false + + private val ref = IntArray(npix) + private val refAcc = IntArray(npix) + private val nuc = IntArray(npix) + private val gray160 = ByteArray(npix) + private val gray320 = ByteArray(npix * 4) + + // ---- render state ---- + private val hist1024 = IntArray(1024) + private val cdf = IntArray(1024) + private val lut = ByteArray(1024) + private val curve = IntArray(1024) + private var winLo = 0 + private var winHi = 0 + private var statMin = 0 + private var statMax = 0 + private var statMean = 0 + private var statStd = 0 + private var centerPrev = 341 + private var meanPrev = 0 + + // ---- output ---- + val outWidth: Int = w * 2 + val outHeight: Int = h * 2 + + private val lock = Any() + + /** Load the official DDT calibration data (raw file bytes). */ + fun loadDdt(bytes: ByteArray): Boolean = synchronized(lock) { + if (bytes.size < 0x98) return false + val magic = ByteReader.rd32(bytes, 0) + if (magic != 0x5AA50003 && magic != 0x5AA50004) return false + if (ByteReader.rd32(bytes, 4) != w || ByteReader.rd32(bytes, 8) != h) return false + epCount = ByteReader.rd32(bytes, 12) + nsegs = ByteReader.rd32(bytes, 16) + if (epCount < 2 || epCount > 64 || nsegs < 1 || nsegs > 8) return false + val v4 = magic == 0x5AA50004 + dev24 = ByteReader.rd32(bytes, 20) + dev4cShift = 3 + if (!v4) { + c5c = ByteReader.rd32(bytes, 28) + c60 = ByteReader.rd32(bytes, 32) + if (c5c > 0x10000) c5c = 0 + } else { + c5c = ByteReader.rd32(bytes, 0x2C + 0x120) + c60 = ByteReader.rd32(bytes, 0x30 + 0x120) + } + if (c5c and 1 == 1) c5c = c5c and 1.inv() + val arro = if (v4) 0x120 + 36 else 36 + var off = arro + if (off + epCount * 12 > bytes.size) return false + for (e in 0 until epCount) { + epTemps[e] = ByteReader.rd32(bytes, off + e * 4) + } + off += epCount * 4 + off += epCount * 4 // skip array 2 + off += epCount * 4 // skip array 3 + if (off + (epCount - 1) * 4 > bytes.size) return false + for (e in 0 until epCount - 1) { + blindCnt[e] = ByteReader.rd32(bytes, off + e * 4) + if (blindCnt[e] > 4096) blindCnt[e] = 4096 + } + blindCnt[epCount - 1] = 0 + off += (epCount - 1) * 4 + + val thrSz = (nsegs - 1) * npix + c5c / 2 + val gainSz = nsegs * npix * 2 + for (e in 0 until epCount) { + if (off + thrSz * 2 + gainSz * 2 > bytes.size) return false + val thr = ShortArray(thrSz) + for (i in 0 until thrSz) { + thr[i] = ByteReader.rd16(bytes, off + i * 2).toShort() + } + off += thrSz * 2 + val gain = IntArray(gainSz) + ByteReader.decodeU16(bytes, off, gainSz, gain) + off += gainSz * 2 + epThr[e] = thr + epGain[e] = gain + } + for (e in 0 until epCount - 1) { + val n = blindCnt[e] + if (n > 0) { + if (off + n * 40 > bytes.size) return false + val rec = IntArray(n * 10) + for (i in 0 until n * 10) { + rec[i] = ByteReader.rd32(bytes, off + i * 4) + } + off += n * 40 + epBlind[e] = rec + } else { + epBlind[e] = null + } + } + thrWork = ShortArray(thrSz) + gainWork = IntArray(gainSz) + return true + } + + private fun selectEndpoint(shutter: Int): Int { + var s = 0 + if (epCount != 2) { + while (s < epCount - 2 && shutter > epTemps[s + 1]) s++ + } + return s + } + + private fun rebuildTables(shutter: Int) { + sel = selectEndpoint(shutter) + val t = (((shutter - epTemps[sel]) shl 12) / (epTemps[sel + 1] - epTemps[sel])) + .coerceIn(-0x3FFF, 0x3FFF) + val a = epThr[sel]!! + val b = epThr[sel + 1]!! + val nThr = (nsegs - 1) * npix + c5c / 2 + for (i in 0 until nThr) { + val av = a[i].toInt() + thrWork[i] = (av + (((b[i].toInt() - av) * t) shr 12)).toShort() + } + val ga = epGain[sel]!! + val gb = epGain[sel + 1]!! + val nGain = nsegs * npix * 2 + for (i in 0 until nGain) { + gainWork[i] = ga[i] + (((gb[i] - ga[i]) * t) shr 12) + } + blindWork = epBlind[sel] + blindCount = blindCnt[sel] + } + + private fun refPush(frame: IntArray) { + if (refCnt == 0) { + for (i in 0 until npix) refAcc[i] = frame[i] + } else { + for (i in 0 until npix) refAcc[i] += frame[i] + } + refCnt++ + if (refCnt >= 4) { + for (i in 0 until npix) ref[i] = refAcc[i] shr 2 + refCnt = 0 + hasRef = true + } + } + + private fun nucAndBlind(f20: IntArray, out: IntArray) { + val thr = thrWork + val gg = gainWork + for (i in 0 until npix) { + val d2 = (f20[i] - ref[i]) shr 1 + var seg = 0 + if (d2 > thr[i * 2].toInt()) { + seg = 1 + if (d2 > thr[i * 2 + 1].toInt()) seg = 2 + } + val base = (seg * npix + i) * 2 + var v = gg[base + 1] + ((gg[base] * d2) shr 12) + if (v < 0) v = 0 else if (v > 65535) v = 65535 + out[i] = v + } + val bw = blindWork + if (bw != null && blindCount > 0) { + for (k in 0 until blindCount) { + val base = k * 10 + val target = bw[base] + val typ = bw[base + 1] + if (target >= npix || typ < 3 || typ > 8) continue + var sum = 0L + for (j in 0 until typ) sum += out[bw[base + 2 + j]] + when (typ) { + 8 -> out[target] = (sum shr 3).toInt() + 4 -> out[target] = (sum shr 2).toInt() + else -> out[target] = (sum / typ).toInt() + } + } + } + } + + private fun statsWindow() { + var ssum = 0L + var s2sum = 0L + var mn = 0xFFFF + var mx = 0 + for (i in 0 until npix) { + val v = nuc[i] + ssum += v + s2sum += v.toLong() * v + if (v < mn) mn = v + if (v > mx) mx = v + } + statMin = mn + statMax = mx + statMean = (ssum / npix).toInt() + val dmean = ssum.toDouble() / npix + val variance = s2sum.toDouble() / npix - dmean * dmean + statStd = sqrt(if (variance > 0) variance else 0.0).toInt() + if (mn == mx) { + winLo = mn + winHi = mn + lut.fill(0x80.toByte()) + gray160.fill(0x80.toByte()) + return + } + var base = (dev24 * 1000) shr dev4cShift + if (base < 0x80) base = 0x80 + val half = base shr 1 + var lo = statMean - half + if (lo > statMin) lo = statMin + if (lo < 0) lo = 0 + var hi = statMean + half + if (hi < statMax) hi = statMax + if (hi > 65535) hi = 65535 + winLo = lo + winHi = hi + if (winHi <= winLo) winHi = winLo + 1 + } + + private fun lutRebuild() { + // The vendor code is 32-bit unsigned arithmetic; products/subtractions + // wrap mod 2^32. u32() replicates that so results match bit-for-bit. + val span = (winHi - winLo).coerceAtLeast(1) + val s = 0xFFC00000L / span + hist1024.fill(0) + for (i in 0 until npix) { + val idx = u32((nuc[i] - winLo).toLong() * s) shr 0x16 + if (idx < 1024) hist1024[idx.toInt()]++ + } + var i = 0 + while (i < 1023) { + hist1024[i] = (hist1024[i + 1] + hist1024[i]) shr 1 + hist1024[i + 1] = (hist1024[i + 2] + hist1024[i + 1]) shr 1 + hist1024[i + 2] = (hist1024[i + 3] + hist1024[i + 2]) shr 1 + i += 3 + } + var total = 0L + for (k in 0 until 1024) total += hist1024[k] + var u21 = total shr 12 + if (u21 == 0L) u21 = 1 + var lres = ((statMean - winLo).toLong() * 0x3FF / span).toInt() + if (lres < 0) lres = 0 + if (lres > 1023) lres = 1023 + if (cdfPivot75) { + var acc = 0L + var k = 0 + while (k < 1024) { + acc += hist1024[k] + if (acc >= (total * 3 shr 2)) break + k++ + } + if (k < 1024 && k > lres) lres = k + } + var u11 = 0L + var u13 = 0L + var k = lres + while (k >= 0) { + u11 += hist1024[k] + if (u21 <= u11) { + u13 += 0x100 + u11 = 0 + } + cdf[k] = (((0x10000L / u21) * u11 shr 8) + u13).toInt() + k-- + } + u11 = 0 + u13 = 0 + k = lres + 1 + while (k < 1024) { + u11 += hist1024[k] + if (u11 < u21 * 8) { + if (u21 <= u11) { + u13 += 0x100 + u11 = 0 + } + } else { + u13 += 0x200 + u11 = 0 + } + cdf[k] = (((0x10000L / u21) * u11 shr 8) + u13).toInt() + k++ + } + for (j in 0 until 1024) { + val v = ((0x300000L + j * 0x800L) shr 0xD).toInt() + curve[j] = if (v != 0) v else 1 + } + var iv6 = dev48 * 0x155 / 200 + var iv7 = iv6 + 0x155 + var iv26 = iv7 + if (iv6 + 0x156 > 0x331) iv26 = 0x330 + var iv10 = 0x332 + var b3 = true + val iv22 = 0xFFC00000L / span + while (iv26 + 1 < iv10) { + iv7 = (iv10 + iv26) / 2 + if (b3) { + b3 = false + iv7 = iv26 + } + var u5 = cdf[0].toLong() + if (u5 != 0L) { + val denom = (iv7 * 0x40000L + 0x40000L) / u5 + val base = iv7 * 0x10L + 0x10L + var u21v = base + var j = lres + while (j >= 0) { + val shifted = u32(cdf[j].toLong() * denom) shr 0xE + var u12 = u32(base - shifted) + val over = u32(u21v - u12) + if (curve[j] < over) u12 = u32(u21v - curve[j]) + lut[j] = (u12 shr 6).toByte() + u21v = u12 + j-- + } + } + u5 = cdf[1023].toLong() + if (u5 != 0L) { + val denom = u32(0xFFC0000L - iv7 * 0x40000L) / u5 + val base = iv7 * 0x10L + var u21v = base + var j = lres + 1 + while (j < 1024) { + val shifted = u32(cdf[j].toLong() * denom) shr 0xE + var u12 = u32(shifted + base) + val over = u32(u12 - u21v) + if (curve[j] < over) u12 = u32(curve[j] + u21v) + lut[j] = (u12 shr 6).toByte() + u21v = u12 + j++ + } + } + var iv4 = iv7 + val bfmin = u32(u32((statMin - winLo).toLong()) * iv22) shr 0x16 + val bfmax = u32(u32((statMax - winLo).toLong()) * iv22) shr 0x16 + if (bfmin < 1024 && bfmax < 1024 && + (lut[bfmin.toInt()].toInt() and 0xFF) == 0 && (lut[bfmax.toInt()].toInt() and 0xFF) != 0xFF + ) { + iv4 = iv10 + iv26 = iv7 + } + iv10 = iv4 + } + var dm = statMean - meanPrev + if (dm < 0) dm = -dm + meanPrev = statMean + var iv18 = iv10 + ((dm and 0xFFFF) shl dev4cShift) * 2 + statMean + if (iv18 < 0x9C4) { + if (iv18 < 100) iv18 = 100 + iv7 = (iv18 * iv7 + centerPrev * 500) / (iv18 + 500) + centerPrev = iv7 + var u5 = cdf[0].toLong() + if (u5 != 0L) { + val denom = (iv7 * 0x40000L + 0x40000L) / u5 + val base = iv7 * 0x10L + 0x10L + var u21v = base + var j = lres + while (j >= 0) { + val shifted = u32(cdf[j].toLong() * denom) shr 0xE + var u12 = u32(base - shifted) + val over = u32(u21v - u12) + if (curve[j] < over) u12 = u32(u21v - curve[j]) + lut[j] = (u12 shr 6).toByte() + u21v = u12 + j-- + } + } + u5 = cdf[1023].toLong() + if (u5 != 0L) { + val denom = u32(0xFFC0000L - iv7 * 0x40000L) / u5 + val base = iv7 * 0x10L + var u21v = base + var j = lres + 1 + while (j < 1024) { + val shifted = u32(cdf[j].toLong() * denom) shr 0xE + var u12 = u32(shifted + base) + val over = u32(u12 - u21v) + if (curve[j] < over) u12 = u32(curve[j] + u21v) + lut[j] = (u12 shr 6).toByte() + u21v = u12 + j++ + } + } + } + } + + private fun grayMap() { + val s = 0xFFC00000L / (winHi - winLo).coerceAtLeast(1) + for (i in 0 until npix) { + val v = nuc[i] + val idx = when { + v <= winLo -> 0L + v >= winHi -> 1023L + else -> u32((v - winLo).toLong() * s) shr 0x16 + } + gray160[i] = lut[idx.coerceAtMost(1023L).toInt()] + } + } + + /** 32-bit unsigned wrap (C unsigned int semantics). */ + private fun u32(x: Long): Long = x and 0xFFFFFFFFL + + private fun upscale2x() { + val W = w + val H = h + val W2 = W * 2 + val s = gray160 + val o = gray320 + for (y in 0 until H - 1) { + val r0 = y * W + val r1 = r0 + W + val o0 = y * W2 * 2 + val o1 = o0 + W2 + var x = 0 + while (x < W - 2) { + val a0 = s[r0 + x].toInt() and 0xFF + val a1 = s[r0 + x + 1].toInt() and 0xFF + val a2 = s[r0 + x + 2].toInt() and 0xFF + val b0 = s[r1 + x].toInt() and 0xFF + val b1 = s[r1 + x + 1].toInt() and 0xFF + val b2 = s[r1 + x + 2].toInt() and 0xFF + val c = x * 2 + o[o0 + c] = a0.toByte() + o[o0 + c + 1] = ((a0 + a1) shr 1).toByte() + o[o0 + c + 2] = a1.toByte() + o[o0 + c + 3] = ((a2 + a1) shr 1).toByte() + o[o1 + c] = ((b0 + a0) shr 1).toByte() + o[o1 + c + 1] = ((b0 + b1 + a0 + a1) shr 2).toByte() + o[o1 + c + 2] = ((b1 + a1) shr 1).toByte() + o[o1 + c + 3] = ((b2 + b1 + a2 + a1) shr 2).toByte() + x += 2 + } + val a0 = s[r0 + W - 2].toInt() and 0xFF + val a1 = s[r0 + W - 1].toInt() and 0xFF + val b0 = s[r1 + W - 2].toInt() and 0xFF + val b1 = s[r1 + W - 1].toInt() and 0xFF + var c = (W - 2) * 2 + o[o0 + c] = a0.toByte() + o[o0 + c + 1] = ((a1 + a0) shr 1).toByte() + o[o1 + c] = ((b0 + a0) shr 1).toByte() + o[o1 + c + 1] = ((a1 + b1 + b0 + a0) shr 2).toByte() + c = (W - 1) * 2 + o[o0 + c] = a1.toByte() + o[o0 + c + 1] = (((3 * a1) shr 2) + (a0 shr 2)).toByte() + o[o1 + c] = ((b1 + a1) shr 1).toByte() + o[o1 + c + 1] = ((3 * (b1 + a1) + b0 + a0) shr 3).toByte() + } + val y = H - 1 + val r0 = y * W + val r1 = r0 - W + val o0 = y * W2 * 2 + val o1 = o0 + W2 + var x = 0 + while (x < W - 2) { + val a0 = s[r0 + x].toInt() and 0xFF + val a1 = s[r0 + x + 1].toInt() and 0xFF + val a2 = s[r0 + x + 2].toInt() and 0xFF + val u0 = s[r1 + x].toInt() and 0xFF + val u1 = s[r1 + x + 1].toInt() and 0xFF + val u2 = s[r1 + x + 2].toInt() and 0xFF + val c = x * 2 + o[o0 + c] = a0.toByte() + o[o0 + c + 1] = ((a0 + a1) shr 1).toByte() + o[o0 + c + 2] = a1.toByte() + o[o0 + c + 3] = ((a2 + a1) shr 1).toByte() + o[o1 + c] = (((3 * a0) shr 2) + (u0 shr 2)).toByte() + o[o1 + c + 1] = ((3 * (a0 + a1) + u0 + u1) shr 3).toByte() + o[o1 + c + 2] = (((3 * a1) shr 2) + (u1 shr 2)).toByte() + o[o1 + c + 3] = ((3 * (a2 + a1) + u2 + u1) shr 3).toByte() + x += 2 + } + val a0 = s[r0 + W - 2].toInt() and 0xFF + val a1 = s[r0 + W - 1].toInt() and 0xFF + val u0 = s[r1 + W - 2].toInt() and 0xFF + val u1 = s[r1 + W - 1].toInt() and 0xFF + var c = (W - 2) * 2 + o[o0 + c] = a0.toByte() + o[o0 + c + 1] = ((a1 + a0) shr 1).toByte() + o[o1 + c] = (((3 * a0) shr 2) + (u0 shr 2)).toByte() + o[o1 + c + 1] = ((3 * (a0 + a1) + u0 + u1) shr 3).toByte() + c = (W - 1) * 2 + o[o0 + c] = a1.toByte() + o[o0 + c + 1] = ((3 * a1 + a0) shr 2).toByte() + o[o1 + c] = ((3 * a1 + u1) shr 2).toByte() + o[o1 + c + 1] = (((o[o0 + c + 1].toInt() and 0xFF) + (o[o1 + c].toInt() and 0xFF) + a1) / 3).toByte() + } + + private fun ffcStep(shutterIn: Int): Int { + if (ffcIdx < 0) { + onFfc?.invoke(0) + ffcIdx = 0 + shutterAtFfc = shutterIn + } + ffcIdx++ + val idx = ffcIdx + if (idx <= 5) { + if (idx == 1) rebuildTables(shutterAtFfc) + return 1 + } + if (idx <= 9) { + if (idx == 9) onFfc?.invoke(1) + if (idx == 6) { + refCnt = 0 + hasRef = false + } + return 2 // ref collection window + } + if (idx <= 13) return 1 + // normal frame: FFC condition + var drift = shutterIn - shutterAtFfc + if (drift < 0) drift = -drift + val cool = 13 + var doFfc = false + if (manualFfc) { + manualFfc = false + doFfc = true + } + if (force75 && !startupFfcDone && idx == 75) { + startupFfcDone = true + doFfc = true + } + if (idx >= 4 + ffcPeriod) { + doFfc = true + } else if (idx >= cool + 30 && drift > ffcDrift) { + doFfc = true + } + if (doFfc) { + onFfc?.invoke(0) + ffcIdx = 0 + shutterAtFfc = shutterIn + return 1 + } + return 0 + } + + /** + * Process one raw frame. + * @param frame USB frame buffer (with or without the 0x38-byte header) + * @param hasHdr true when the 28-byte header is at frame[0] + * @param outArgb 320x240 ARGB IntArray output (outW*outH entries) + * @return true when outArgb was written; false while in the startup/FFC window. + */ + fun frame(frame: ByteArray, hasHdr: Boolean, outArgb: IntArray): Boolean = synchronized(lock) { + globalFrames++ + if (globalFrames < warmFrames) return false + + var shutterValue = shutter + if (hasHdr) { + val len = ByteReader.rd32(frame, 8) + if (len == npix * 2) { + shutterValue = ByteReader.rd32(frame, 0x1C + len + 8) + } + } + shutter = shutterValue + + val st = ffcStep(shutterValue) + if (st != 0) { + if (st == 2) { + val off = if (hasHdr) 0x1C else 0 + ByteReader.decodeU16(frame, off, npix, nuc) + refPush(nuc) + } + return false + } + + val off = if (hasHdr) 0x1C else 0 + ByteReader.decodeU16(frame, off, npix, nuc) + if (!hasRef) return false + + nucAndBlind(nuc, nuc) + statsWindow() + lutRebuild() + grayMap() + upscale2x() + // palette colorize 320x240 -> ARGB + val pal = OfficialTables.PALETTE256_ARGB + var i = 0 + while (i < npix * 4) { + outArgb[i] = pal[gray320[i].toInt() and 0xFF] + i++ + } + return true + } + + fun setShutter(value: Int) { + synchronized(lock) { shutter = value } + } + + /** Manual FFC trigger (official FFC button). */ + fun requestFfc() { + synchronized(lock) { manualFfc = true } + } + + data class Stats( + val winLo: Int, val winHi: Int, + val min: Int, val max: Int, + val mean: Int, val std: Int, + ) + + fun stats(): Stats = synchronized(lock) { + Stats(winLo, winHi, statMin, statMax, statMean, statStd) + } + + fun hasReference(): Boolean = synchronized(lock) { hasRef } + fun frameIndex(): Int = synchronized(lock) { ffcIdx } + fun endpoint(): Int = synchronized(lock) { sel } + + /** Temperature probe on the current NUC output, in millidegrees C. */ + fun probeTemp(x: Int, y: Int): Int = synchronized(lock) { + TempMath.countsToTempMc(nuc[y * w + x]) + } + + /** Read the current NUC counts buffer (already blind-compensated). */ + fun copyNuc(out: IntArray) = synchronized(lock) { + System.arraycopy(nuc, 0, out, 0, minOf(npix, out.size)) + } + + /** Debug: full intermediate state, same layout as analysis/debug_lut.c. */ + internal fun debugState(): String = synchronized(lock) { + val sb = StringBuilder() + sb.append("win $winLo $winHi\n") + sb.append("hist") + for (v in hist1024) sb.append(' ').append(v) + sb.append("\ncdf") + for (v in cdf) sb.append(' ').append(v) + sb.append("\nlut") + for (v in lut) sb.append(' ').append(v.toInt() and 0xFF) + sb.append("\ngray") + for (v in gray160) sb.append(' ').append(v.toInt() and 0xFF) + sb.append("\ngray320") + for (v in gray320) sb.append(' ').append(v.toInt() and 0xFF) + sb.append('\n') + sb.toString() + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt new file mode 100644 index 0000000..efc18c6 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/TempMath.kt @@ -0,0 +1,52 @@ +package com.mag160c.thermal.core + +/** + * Temperature math ported from csdk/src/mag160c_render.c counts_to_temp_mc + * and csdk/src/mag160c_temp.c t2e_interp. All temperatures are integer + * millidegrees Celsius (0.001 C units). + */ +object TempMath { + private const val T2E_OFFSET = 0x249F0 + private const val TEMP_C = 5797 + private val T2E = OfficialTables.T2E + private val T2E274 = OfficialTables.T2E274 + + /** + * Convert NUC counts to millidegrees C via the official T2E curve + * (646-entry inverse map, Q12 interpolation). + */ + fun countsToTempMc(counts: Int): Int { + var x = counts.toLong() * 3 - TEMP_C + if (x < 0) x = 0 + var lo = 0 + var hi = 645 + while (lo < hi) { + val mid = (lo + hi + 1) shr 1 + if (T2E[mid] <= x) lo = mid else hi = mid - 1 + } + var i = lo + if (i > 644) i = 644 + val diff = x - T2E[i] + val t2 = (T2E[i + 1] - T2E[i]).toLong() + val slope = if (t2 != 0L) (0x1000000L + t2 / 2) / t2 else 0L + val temp = ((slope * diff) shr 12) + (i.toLong() shl 12) - T2E_OFFSET + return temp.toInt() + } + + /** + * T2E piecewise-linear evaluation with Q13 band selection + * (vendor ReviseTemperature/CorrectTemperature core, 274-entry curve). + */ + fun t2eInterp(value: Int): Int { + val v = value + 0xC350 + var idx = v shr 13 + if (idx < 0) idx = 0 else if (idx > T2E274.size - 2) idx = T2E274.size - 2 + val t0 = T2E274[idx] + val t1 = T2E274[idx + 1] + val d = v - (idx shl 13) + val prod = d * (t1 - t0) + var interp = prod shr 13 + if (interp < 0 && (prod and 0x1FFF) != 0) interp += 1 + return t0 + interp + } +} diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/RenderPipelineTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/RenderPipelineTest.kt new file mode 100644 index 0000000..c3322dc --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/RenderPipelineTest.kt @@ -0,0 +1,105 @@ +package com.mag160c.thermal.core + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Differential test: the Kotlin render pipeline must produce byte-identical + * output to the pixel-verified C implementation (csdk/src/mag160c_render.c), + * which was validated against CoreSDKLib.dll. Reference files were produced + * by analysis/render_offline.c with the official DDT + a synthetic frame + * sequence (warm=20, ffc_period=1800, ffc_drift=250, cdf pivot 75, forced + * frame-75 startup FFC, FFC callback noop). + */ +class RenderPipelineTest { + private fun res(name: String): ByteArray = + javaClass.classLoader.getResourceAsStream(name)!!.readBytes() + + @Test + fun matchesCReferencePixelExact() { + val ddt = res("mag160c_official.ddt") + val frames = res("test_frames.bin") + val refRgb = res("test_ref.rgb") + val refStats = res("test_ref_stats.txt").decodeToString() + + val pipeline = RenderPipeline( + w = 160, h = 120, + ffcPeriod = 1800, ffcDrift = 250, warmFrames = 20, + cdfPivot75 = true, force75 = true, + onFfc = {}, + ) + assertTrue("DDT must load", pipeline.loadDdt(ddt)) + + val frameSize = 0x38 + 38400 + val nframes = frames.size / frameSize + val out = IntArray(320 * 240) + val renderedFrames = ArrayList() + for (f in 0 until nframes) { + val fr = frames.copyOfRange(f * frameSize, (f + 1) * frameSize) + if (pipeline.frame(fr, true, out)) { + renderedFrames.add(out.copyOf()) + } + } + + // reference: hidden frames end with "HIDDEN", rendered frames have 6 ints + val refRendered = refStats.trim().lines().count { !it.endsWith("HIDDEN") } + assertEquals("rendered frame count", refRendered, renderedFrames.size) + + // per-pixel comparison (C writes RGB24; Kotlin produces the same ARGB ints) + val rgb = res("test_ref.rgb") + var off = 0 + for (fi in renderedFrames.indices) { + val expect = IntArray(320 * 240) + for (i in 0 until 320 * 240) { + val r = refRgb[off].toInt() and 0xFF + val g = refRgb[off + 1].toInt() and 0xFF + val b = refRgb[off + 2].toInt() and 0xFF + expect[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + off += 3 + } + assertArrayEquals("frame $fi pixels", expect, renderedFrames[fi]) + } + assertEquals("consumed all reference bytes", refRgb.size, off) + } + + @Test + fun tempConversionIsMonotonicAndSane() { + // protocol_spec: hand vs background ~1322 counts delta; 0x1000-ish counts + // are ambient-scale. Sanity: monotonic and plausible millidegrees. + var prev = Int.MIN_VALUE + for (counts in 6000..8000 step 10) { + val t = TempMath.countsToTempMc(counts) + assertTrue("monotonic at $counts", t > prev) + prev = t + } + // ~20-40 C around the 7000-count region (millidegrees) + val t20 = TempMath.countsToTempMc(7000) + assertTrue("temp in plausible range: $t20", t20 in 0..60_000) + } + + @Test + fun frameStreamReassemblesSplitReads() { + val frames = res("test_frames.bin") + val frameSize = 0x38 + 38400 + val stream = FrameStream(38400) + val out = ByteArray(frameSize) + var got = 0 + var pos = 0 + var seed = 7 + while (pos < frames.size) { + seed = (seed * 1103515245 + 12345) and 0x7FFFFFFF + val n = minOf(1 + (seed % 3000), frames.size - pos) + val len = stream.push(frames.copyOfRange(pos, pos + n), n, out) + if (len > 0) { + for (i in 0 until frameSize) { + assertEquals("byte $i at frame $got", frames[got * frameSize + i], out[i]) + } + got++ + } + pos += n + } + assertEquals("all frames reassembled", frames.size / frameSize, got) + } +} diff --git a/android/app/src/test/resources/mag160c_official.ddt b/android/app/src/test/resources/mag160c_official.ddt new file mode 100644 index 0000000..75a1364 --- /dev/null +++ b/android/app/src/test/resources/mag160c_official.ddt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48ee01f70dff12d5950b425c3999e866c1ec21a162e5da6a33ed0e5b7cfbd191 +size 1856416 diff --git a/android/app/src/test/resources/test_frames.bin b/android/app/src/test/resources/test_frames.bin new file mode 100644 index 0000000..ab43c1a --- /dev/null +++ b/android/app/src/test/resources/test_frames.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5607f3925b9e7415c02c6e0c9d84f24eb3015934edf9d832082d4350d2aecdb +size 2307360 diff --git a/android/app/src/test/resources/test_ref.rgb b/android/app/src/test/resources/test_ref.rgb new file mode 100644 index 0000000..8b2a417 --- /dev/null +++ b/android/app/src/test/resources/test_ref.rgb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dea125755ff6238b4ccac80d6196e583d264c5a3b0d8350cebf94b4524cf890d +size 6451200 diff --git a/android/app/src/test/resources/test_ref_stats.txt b/android/app/src/test/resources/test_ref_stats.txt new file mode 100644 index 0000000..61ee250 --- /dev/null +++ b/android/app/src/test/resources/test_ref_stats.txt @@ -0,0 +1,60 @@ +0 HIDDEN +1 HIDDEN +2 HIDDEN +3 HIDDEN +4 HIDDEN +5 HIDDEN +6 HIDDEN +7 HIDDEN +8 HIDDEN +9 HIDDEN +10 HIDDEN +11 HIDDEN +12 HIDDEN +13 HIDDEN +14 HIDDEN +15 HIDDEN +16 HIDDEN +17 HIDDEN +18 HIDDEN +19 HIDDEN +20 HIDDEN +21 HIDDEN +22 HIDDEN +23 HIDDEN +24 HIDDEN +25 HIDDEN +26 HIDDEN +27 HIDDEN +28 HIDDEN +29 HIDDEN +30 HIDDEN +31 HIDDEN +32 6961 7961 7118 7668 7461 103 +33 6962 7962 7098 7663 7462 103 +34 6962 7962 7116 7675 7462 103 +35 6961 7961 7106 7672 7461 103 +36 6961 7961 7106 7659 7461 103 +37 6961 7961 7116 7664 7461 103 +38 6961 7961 7121 7671 7461 103 +39 6962 7962 7117 7673 7462 103 +40 6962 7962 7132 7667 7462 103 +41 6962 7962 7105 7664 7462 103 +42 6961 7961 7118 7671 7461 103 +43 6961 7961 7130 7671 7461 103 +44 6962 7962 7122 7661 7462 103 +45 6961 7961 7096 7669 7461 103 +46 6961 7961 7096 7671 7461 103 +47 6962 7962 7122 7675 7462 103 +48 6961 7961 7094 7667 7461 103 +49 6962 7962 7099 7664 7462 103 +50 6962 7962 7134 7669 7462 103 +51 6962 7962 7133 7662 7462 103 +52 6961 7961 7129 7671 7461 103 +53 6962 7962 7126 7671 7462 103 +54 6961 7961 7119 7669 7461 103 +55 6961 7961 7096 7673 7461 103 +56 6961 7961 7135 7661 7461 103 +57 6962 7962 7099 7662 7462 103 +58 6961 7961 7122 7659 7461 103 +59 6962 7962 7116 7669 7462 103 diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..9deb573 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..599e9c6 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true +android.useAndroidX=true +kotlin.code.style=official diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..ae71466 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,26 @@ +[versions] +agp = "8.11.1" +kotlin = "2.2.0" +composeBom = "2025.06.01" +activityCompose = "1.10.1" +coreKtx = "1.16.0" +lifecycle = "2.9.1" +junit = "4.13.2" + +[libraries] +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +junit = { group = "junit", name = "junit", version.ref = "junit" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..ea3bc6d --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.jar @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172 +size 43764 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..1a7b500 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "MAG160C" +include(":app") diff --git a/docs/android_app/reverse_apk_features.md b/docs/android_app/reverse_apk_features.md new file mode 100644 index 0000000..bca5d1b --- /dev/null +++ b/docs/android_app/reverse_apk_features.md @@ -0,0 +1,105 @@ +# MAG160C 官方 APP 逆向功能总表(2026-09-06) + +> 用 jadx 1.5.1 反编译 4 个 APK,IDA(MCP)分析 pro 版 libcoresdk.so(v7a), +> Ghidra 12.1.2 headless 分析普通版 libcxsdk.so。反编译工作副本在 +> `%TEMP%\opencode\apkwork\jadx_*`(临时目录,已删可重跑 jadx 命令见本文末尾)。 + +## 1. 四个 APP 身份 + +| 包名 | 版本 | 名称 | 原生库 | 定位 | +|---|---|---|---|---| +| cn.com.magnity.magnitycx | 1.1.4 (114) | MAG-Cx 普通版 | libcxsdk + FFmpeg (仅 armeabi) | USB 实时拍摄/录像 | +| cn.com.magnity.magnitymx | 2.0.7 (27) | MAG-Mx 专业版 | libcoresdk(新版libusb) + libthermogroupsdk (仅 armeabi-v7a) | USB+网络设备、任务巡检、云 | +| cn.com.magnity.thermoscope | 1.0.2 (2) | Thermo Scope | libthermogroupsdk (仅 armeabi-v7a) | MDT 热像文件离线分析+PDF 报告 | +| cn.com.magnity.coresdksample | 1.0 | CoreSdkSample | libcoresdk (旧) | SDK 示例(无独立功能) | + +- 全部 targetSdk 25(Android 7.1 时代);专业版/普通版 MainActivity 锁定 landscape。 +- USB:VID 0x833C。普通版 PID=1;专业版同时支持 PID=1(160Core/coresdk) 与 PID=2(Mx 网络机芯)。 + +## 2. 普通版 MAG-Cx 功能(jadx_normal/sources/cn/com/magnity/magnitycx) + +**实时画面**:SurfaceView 软件渲染(lockCanvas→drawBitmap),IR 160×120→640×480 合成画布 +- 12 调色板(白热/黑热/铁虹/彩虹/琥珀/金秋/寒冬/热金属/喷射/红饱和/高对比/红热)DialogFragmentPalette.java +- 中心点测温、全屏最高/最低温追踪(无/最高/最低/全部)、矩形 ROI(最值+均值,MAX_ROI_NUM=1)ImageViewer.java +- PIP 可见光小窗(手机 Camera API 预览,可拖动/三档尺寸)ImageViewerVisible.java;无像素级融合 +- 数码变倍 1/2/4/8/16x(160C 仅 1/2/4 循环);双击=手动 FFC;Fling 切拍照/录像 +- 录像 MP4(FFmpeg avcodec-57 + libcxsdk doRecording,640×480,2048kbps/20fps) +- 拍照 JPEG q100 640×480 含 OSD(十字/最值/水印/激光);仅 C3P 另存 .ddt 伴随文件 +- 横竖屏 OrientationEventListener 实时切 native 旋转角;来电自动断 USB;退后台 500ms deinit + +**USB/协议**(UsbCommunication.java,端点 0x03/0x82/0x81/0x84,28B 帧头尾协议同 csdk) +- 连接握手:GetParameter1/2 → GetCaliInfo → 无本地标定则 GetCaliFile 下载(others\{产品}.{SN}.{日期}) +- 启动:native startProcess(...) + StartTransferImg;ThreadImgRecv/Process 双线程;16 帧一次查开机时长并按时间表 FFC +- 命令集 P2D_*(0x6BB6B6xx)与 D2P_*(0x5BB5B5xx),与 csdk/protocol_spec.md 完全一致 + +**设置项**:可见光尺寸、温度追踪目标、矩形测温开关、H/V 翻转、变倍、辐射率 4 档(1.00/0.90/0.80/0.70)、帧率高/低、移动光标 +**媒体库**:GridView+LRU、多选分享/删除、ViewPager 浏览、视频外部播放器 +**升级**:http://www.magnity.com.cn/APPs/UpgradeInfo(产品键 MAG-Cx) + +## 3. 专业版 MAG-Mx 功能(jadx_pro/sources/cn/com/magnity/magnitymx) + +**双设备模型**:CXDeviceModel(USB coresdk,PID1)+ DeviceModel(网络/云/USB-Mx thermogroup,PID2;命令口 33596、图像口 33597,云中继 121.43.190.114,账密 magnity/any123、admin/admin123) + +**实时画面**(LiveFragment+MainActivity,竖直 LinearLayout) +- 工具栏:任务面板、logo 点击=FFC、设置、调参条(tunebar)、PIP 切换(长按=可见光窗口调整)、最高温追踪、缩略图、ROI、拍照(长按=MGS 录像)、暂停、设备列表 +- tunebar 8 项:手动拉伸/等温线/发射率/调色板/温度报警/任务/红外基准图/扫码/码流 +- 12 调色板;发射率 35 种材料表+自定义(DialogFragmentEmissivity) +- ROI:点/线/框,区域 ROI 弹名称+报警上下限(DialogFragmentRoiAlarmTempAddEdit);报警声+红色闪烁 +- 可见光 Camera2 融合(alpha 混合、拖动/双指缩放、拍照取 JPEG 写入 MDT);闪光灯 +- 图像增强:手动两点拉伸/自动拉伸(range5)/等温线;图像旋转 0/90/180/270 +- 参考图叠加(基准图 alpha 调整,拍照时 blendBitmap 融合) +- 码流类型:温度流2/视频流4/混合流6(网络设备) +- 拍照 = 640×480 JPG + saveDDT2Buffer + ROI + 可见光 + GPS(EXIF) → **MDT 容器** saveMDT,文件名 01-yyMMddHHmmss-<任务名/扫码结果>.jpg +- **录像 = SDK .mgs 专有格式**(仅长按触发,视频流禁用);VideoEncoder MP4 通道是死代码 +- 拼接全景:仅 CX/USB 通道实现(startStitching) +- 云台:WiFi SSID 含 HERO-RC → UDP 8484 协议遥控拍照/模式切换(UDPHelper) +- 扫码:ZXing,结果作文件名标记 +- 音量键拍照;GPS 写 EXIF;云设备 10 分钟闲置断流 + +**任务巡检系统**(task/ + assets/tasks) +- SqliteTaskParser:.sqlite 库(省-市-工区-电压-变电站五级 + task 树 + capture_status/order) +- XmlTaskParser:旧 XML 任务;选库后侧滑两级树,点选→参考图叠加→拍照自动跳下一项 +- 任务库放 /sdcard/magnity/mx/tasks(首拷自 assets;云下载 v1/account/taskfiles/{taskId}) + +**媒体库**(media/):宫格、多选(上传/分享/删除/全选)、NetworkManager 上传队列、单张浏览、"设为参考图"、"分析"= 调起 ThermoScope(intent thermoscope://路径)、GPS 跳地图 +**云平台**:Retrofit REST(cloudapi.magnity.com.cn):login/logout/getDevices/upload(task_id)/taskfiles;本地 magnity_mx_files.db 记 MD5 上传状态;CloudSyncTask 云同步;MagnityNetworkService 前台服务上传 +**设置**:账户/相机(仅图像旋转)/语言(自动/简/繁/EN)/主题(占位)/检查更新(magnity.com.cn/APPs/mx)/关于 +**数据库**:Room cloud_device/local_device + SQLite magnity_mx_files.db + +## 4. ThermoScope 功能(jadx_thermoscope/sources/cn/com/magnity/thermoscope) + +- **MDT 容器格式**(types/):尾部 152B Tail{0x5BB5B57B + 5 段绝对偏移};段序 JPG 缩略图→DDT(136B头+38400B 原始 u16 帧)→VIS 可见光→LAB(每 ROI 256B:type/最值/均值/位置/坐标/色/发射率/报警上下限/名字)→TXT(编码 GB18030/GBK/UTF-8)→AUD;段间 4 字节对齐;支持裸 DDT +- 媒体库:扫 DCIM/Magnity/*.jpg(内容实为 MDT),同目录打开模式,按文件名内嵌时间排序 +- 单张分析(SingleMediaActivity + ScopeSurfaceView 软件渲染): + - 11 调色板切换(回写 DDT);参数滑杆:环境温度 -40~80℃ / 发射率 0~1 / 窗口透过率 0~1(native 重算+回写) + - 点测温 Pt*(3x3 探针+发射率修正)、框测温 Rt*(min/max/ave+minPos/maxPos)、垂直滑动切红外/可见光 + - 双指缩放 1~4x、平移;max/min 追踪标记;色标条+6 刻度;保存 640×480 PNG 快照+水印 + - 相机信息(SN/分辨率/版本/时间/海拔/经纬度);文字备注;语音段透传 +- PDF 报告(PdfCreator,tejpratapsingh PDFUtil):标题、7×4 基本信息、热像图、9 级色标、整体+ROI 分析表、结果/建议、测试员;输出 DCIM/Magnity/reports/;ReportSettingActivity 15 项报告参数 +- RelayActivity:外部打开 .mdt/.ddt/.jpg/pdf 分发 + +## 5. SDK(cn/com/magnity/coresdk + sdk) + +三代演进:coresdk(USB-only, PID1) → thermogroupsdk 基础版(thermoscope: +网络/云/PTZ/人脸) → 完整版(pro: +GetCameraInfoEx/ObjReco/visPlay_no_decode H.264) +- 帧回调只发节拍(newFrame(camState,streamType)),数据用 getOutputImage()/getOutputTempImage(int[])/getTemperatureData(int[]) 拉取 +- 温度单位:毫度 int(×0.001℃);位图 ARGB_8888(IDA 证实 GetOutputImage |0xFF000000 直写、行倒序) +- nativeCopyBitmap:格式/宽高/stride 校验后 qmemcpy +- libcxsdk.so(普通版图像核心)导出 C++ API:CFunctions/CAccumulator/CSmoother(与 ARM64 libcoresdk 同族,JNI_OnLoad 动态注册) + +## 6. 花屏(Android 16 上专业版)根因分析 + +可确认的事实: +1. 专业版仅带 **armeabi-v7a 32 位库**(libcoresdk/libthermogroupsdk),无 arm64。 +2. targetSdk 25 + MainActivity 锁 landscape + 全部旧版 support/自定义 SurfaceView 渲染。 +3. 渲染链:native 位图(ARGB_8888,行倒序)→ Bitmap.createBitmap 拷贝 → lockCanvas/drawBitmap → unlockCanvasAndPost;SurfaceHolder 格式 -2(TRANSPARENT),与格式无关的 Canvas 软渲染。 +4. 现役 2025~2026 旗舰(多数已停 32 位支持或启用 16KB 页内核)上,32 位 v7a 库+4KB 对齐 ELF+legacy 渲染路径极易出现兼容层渲染异常(花屏/缩放模糊/偶发撕裂)。 + +结论:不是单一 bug,而是"32 位专属库 + targetSdk25 时代渲染栈"在 Android 16 设备上的整体不兼容。重写为 targetSdk 36 + arm64(16KB 对齐) + 现代渲染即可根治,无需逐条修旧代码。 + +## 7. 反编译复现命令 + +```powershell +$w='C:\Users\ZXC\AppData\Local\Temp\opencode\apkwork' +& 'C:\Tools\jadx\bin\jadx.bat' -d "$w\jadx_pro" --show-bad-code -j 8 "$w\orig\pro.apk" +# Ghidra: & 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' C:\Tools\ghidra_proj Mag160C -import -scriptPath C:\Tools\ghidra_scripts_user -postScript DumpJni.java +``` diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md new file mode 100644 index 0000000..bfb5f4b --- /dev/null +++ b/docs/android_app/session_state.md @@ -0,0 +1,89 @@ +# MAG160C 安卓统一 APP — 会话状态与心跳锚点 + +> 本文件是本任务("整合 4 个官方 APP 重写为 Android 16 现代化 APP")的心跳锚点。 +> 每完成一个里程碑更新勾选;会话中断先读本文件 + `reverse_apk_features.md`, +> 再按"下一步"继续。约定:不弹询问窗,按计划自主推进,重大分歧点才停下来问。 + +## 任务目标 + +1. 逆向 C:\Project\MAG160C\app 4 个官方 APP(普通版/专业版/ThermoScope/demo)→ 已完成 +2. 整合全部功能开发一款安卓 APP:适配 Android 16(API 36)、现代 UI、横竖屏、 + 无流氓文件夹、干爽简洁 → **进行中** +3. 背景问题:专业版在用户 Android 16 手机上花屏(根因见 reverse_apk_features.md §6) + +## 已完成的里程碑 + +- [x] 工具链:jadx 1.5.1(C:\Tools\jadx)、Ghidra 12.1.2(C:\Tools)、IDA MCP 可用 +- [x] 4 APK 解包 + jadx 全量反编译(%TEMP%\opencode\apkwork\jadx_*) +- [x] 普通版/专业版/ThermoScope/demo 功能清单 → docs/android_app/reverse_apk_features.md +- [x] IDA:pro libcoresdk.so GetOutputImage/copyBitmap 位图格式证实(ARGB_8888) +- [x] Ghidra:libcxsdk.so 导出 C++ API 清单(CFunctions 族,与 ARM64 coresdk 同源) +- [x] 花屏根因分析(32 位专属库 + targetSdk25 legacy 渲染栈) + +## 关键事实速查(开发时直接用) + +- USB:VID 0x833C PID 1,EP 0x03/0x82/0x81/0x84,帧 28B 头+38400B+尾,15fps +- 协议/渲染/温度算法全部已逆向:analysis/protocol_spec.md + csdk/(逐像素验证过的官方管线) +- csdk 的 mag160c_render 无平台依赖:USB 帧 → 320×240 RGB24 + 探针测温(毫度) +- DDT 标定文件:可从设备经 0x6BB6B66D/E 下载(官方 prepareProcessImage 同款),仓库有 + build-artifacts/mag160c_official.ddt(1.8MB,SN 160043865) +- MDT 文件格式:reverse_apk_features.md §4(152B Tail + 5 段布局 + ROI 256B 记录) +- 温度:毫度 int;MDT 解码走 ConvertResponse2Temperature + T2E(Revise) + CorrectTemperature + +## 用户已确认的范围(2026-09-06) + +- 云功能:预留 Retrofit 接口模块,默认关闭 +- 扫码打标 + 任务巡检(sqlite/xml) + 红外基准图叠加:v1 全部实现 +- **网络互连(用户新增需求)**:主机手机插 USB 热像仪,局域网内另一台手机 + 远程实时预览——UDP 广播自动发现 + 手动添加内网 IP;自定义轻量协议 + (控制通道 JSON + 图像流),不走厂商 33596/33597 协议 + +## 当前阶段 + +**阶段 2/8 完成:纯Kotlin核心管线移植并逐字节验证(2026-09-06)** + +## 已完成里程碑(本任务) + +- [x] 阶段1:轻量工具链定案——NDK/CMake/zig 全部卸载(-2.4GB),纯 Kotlin 方案: + 渲染管线+温度算法+帧解析从 C 移植为 Kotlin,Gradle 8.14.3 + SDK 已有组件 +- [x] 阶段2:项目骨架(android/,AGP 8.11.1 + Kotlin 2.2.0 + Compose BOM, + compileSdk/targetSdk 36,minSdk 26),最小APK可构建 +- [x] 阶段2:表数据自动生成 analysis/gen_kotlin_tables.py → OfficialTables.kt + (palette256 ARGB / T2E 646 / T2E274 / E2T_ACC_Q10) +- [x] 阶段2:核心移植 FrameStream / RenderPipeline / TempMath(毫度测温) +- [x] 阶段2:PC端参考工具 analysis/render_offline.c(gcc 编译)+ 差分测试: + **Kotlin 输出与C参考逐字节一致**(60帧序列,首渲染帧32,NUC/LUT/灰度/输出全同) +- [x] 阶段2:FrameStream 分块重组测试 + 温度换算单调性测试全过 + +## 移植陷阱记录(教训) + +- C 无符号32位回绕:乘积/减法必须 u32() 掩码(cdf*denom、0xffc0000-iv7*0x40000、 + (v-win_lo)*S 等) +- lutRebuild 的 u12 基址是常量 iv7*0x10+0x10,**不是**链式 u21v;u21v 只用于曲线钳制比较 +- Kotlin ByteArray 存 255 = -1:lut[bfmax] != 0xFF 判断必须 (b.toInt() and 0xFF) +- 暖机窗口内 FFC 状态机暂停(warm check 在 ffc_step 之前)——移植时保持顺序 + +## 下一步 + +阶段3:USB层(UsbManager + Kotlin bulk 传输 + 命令协议)+ 实时画面 UI + 拍照/录像。 +参考:analysis/protocol_spec.md;文件存储 → MediaStore(DCIM/MAG160C)。 + +## 开发计划(待确认后逐阶段执行) + +- 阶段 1:环境(cmdline-tools/NDK r27+/gradle 8.x + 16KB 对齐配置 + AGP 8.x) +- 阶段 2:新仓库 app-android(Kotlin + Compose M3 + NDK 移植 csdk 渲染/温度 C 代码 + Kotlin USB 层) +- 阶段 3:实时画面(USB 流+官方管线+OSD+ROI+调色板+FFC+增强+PIP 可见光+拍照 MDT+MP4 录像) +- 阶段 4:媒体库(MediaStore,DCIM/MAG160C,无流氓文件夹) +- 阶段 5:MDT 离线分析(缩放/ROI/参数/PDF 报告/备注) +- 阶段 6:设置/任务巡检/可选云(Retrofit opt-in) +- 阶段 7:Android 16 专项适配验收(16KB 对齐/横竖屏/深色/预测性返回) +- 阶段 8:打包安装到用户手机实测 + +## 心跳约定 + +- 每完成一个阶段:更新本文件勾选 + git commit(不 push)。 +- 中断恢复:读本文件 → 按"下一步"继续 → 环境检查命令: + ```powershell + Test-Path C:\Users\ZXC\AppData\Local\Android\Sdk\ndk\ + Get-Command java; Get-Command gradle + ```