aboutsummaryrefslogtreecommitdiff
path: root/docs/disc.html
diff options
context:
space:
mode:
authorAlexander Kassouni <alex@kassouni.net>2026-09-08 20:11:06 -0700
committerAlexander Kassouni <alex@kassouni.net>2026-09-08 20:11:06 -0700
commit9ca1a1c1c7bf56e39b516c299b4e0d3efe59652b (patch)
tree7e41c6f8424fbd021b515f6a95deffda61b4a142 /docs/disc.html
parent24ec2a92b56ca4296fe65b70524e36a8e1c67c2e (diff)
better docs
Diffstat (limited to 'docs/disc.html')
-rw-r--r--docs/disc.html413
1 files changed, 413 insertions, 0 deletions
diff --git a/docs/disc.html b/docs/disc.html
new file mode 100644
index 0000000..5f4a4ae
--- /dev/null
+++ b/docs/disc.html
@@ -0,0 +1,413 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>8. Walking the disc image — Animalese</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<nav class="crumb"><a href="index.html">Animalese pipeline</a> / 8. Walking the disc image</nav>
+
+<h1>8. Walking the disc image</h1>
+<p class="lede">Everything a reimplementation needs to pull the samples and sequences out
+of a dumped disc, byte by byte: the CISO container, the GameCube filesystem, the DOL,
+the five audio tables, the bank blobs, and the VADPCM decoder. Every offset on this page
+was verified against a real <code>GAFE01</code> USA rev&nbsp;0 image.</p>
+
+<p>The chain, outermost to innermost:</p>
+
+<pre>CISO container (optional)
+ └ GameCube disc
+ ├ main DOL ──► the five audio tables (compiled-in .data)
+ └ /audiorom.img ──► Audioseq · Audiobank · Audiowave regions
+ ├ sequences 243/244/245 (dyntbl: phoneme code → instrument + note)
+ ├ banks 156/157/158 (instrument → sample descriptor)
+ └ wave bank 5 (raw VADPCM frames)</pre>
+
+<p>All disc and audio structures are <strong>big-endian</strong>. The single exception is
+the CISO header's block size, which is little-endian.</p>
+
+<h2>1. The CISO container</h2>
+
+<p>A <code>.ciso</code> file is a raw disc image with all-zero blocks removed. The header
+is <code>0x8000</code> bytes:</p>
+
+<table>
+<tr><th>Offset</th><th>Size</th><th>Field</th></tr>
+<tr><td><code>0x0000</code></td><td>4</td><td>magic <code>"CISO"</code> (<code>43 49 53 4F</code>)</td></tr>
+<tr><td><code>0x0004</code></td><td>4</td><td>block size, <strong>little-endian</strong> u32 (commonly <code>0x200000</code>)</td></tr>
+<tr><td><code>0x0008</code></td><td><code>0x7FF8</code></td><td>block map: one byte per logical block; <code>1</code> = stored, <code>0</code> = omitted (reads as zeros)</td></tr>
+</table>
+
+<p>Stored blocks follow the header back to back, in logical order. To read logical
+offset <em>x</em>:</p>
+
+<pre>b = x / block_size
+if map[b] == 0: the bytes are zero
+else: physical = 0x8000 + rank(b) * block_size + x % block_size
+ where rank(b) = number of set map entries before index b</pre>
+
+<p>Precomputing a prefix-sum over the map gives O(1) reads. A plain <code>.iso</code> /
+<code>.gcm</code> is the same image without this layer — detect by checking for the
+<code>CISO</code> magic and fall through to raw offsets if absent.</p>
+
+<h2>2. The GameCube disc</h2>
+
+<p>Only four header fields matter here:</p>
+
+<table>
+<tr><th>Offset</th><th>Size</th><th>Field</th><th>GAFE01 rev 0 value</th></tr>
+<tr><td><code>0x0000</code></td><td>6</td><td>game code — verify <code>"GAFE01"</code></td><td></td></tr>
+<tr><td><code>0x0420</code></td><td>4</td><td>offset of the main DOL</td><td><code>0x0001E000</code></td></tr>
+<tr><td><code>0x0424</code></td><td>4</td><td>offset of the FST</td><td><code>0x000FE500</code></td></tr>
+<tr><td><code>0x0428</code></td><td>4</td><td>size of the FST</td><td><code>0x102</code></td></tr>
+</table>
+
+<h3>The FST</h3>
+
+<p>The filesystem table is an array of 12-byte entries followed by a string table:</p>
+
+<pre>entry {
+ u8 flags; // 0 = file, 1 = directory
+ u24 name_offset; // into the string table (NUL-terminated)
+ u32 offset; // file: absolute disc offset dir: parent index
+ u32 length; // file: byte length dir: index of next sibling
+}</pre>
+
+<p>Entry 0 is the root; its <code>length</code> is the total entry count, and the string
+table starts at <code>fst + count * 12</code>. Animal Crossing's FST has just 11 entries.
+The one that matters:</p>
+
+<pre>audiorom.img offset 0x626D10 size 0x7EA760</pre>
+
+<p>The game opens it by name — <code>Jac_InitARAM</code> checks
+<code>Jac_CheckFile("/audiorom.img")</code> and <code>neosthread.c</code> DMAs the whole file
+into ARAM. Walk the FST by name rather than hardcoding the offset; the size, however, is
+structural (it is the sum of the three region sizes below) and makes a good integrity
+check. <span class="src"><code>internal/dummyrom.c:68</code>,
+<code>internal/neosthread.c:56</code></span></p>
+
+<h2>3. The DOL and the five tables</h2>
+
+<p>The audio tables are compiled into the main executable, not stored in
+<code>audiorom.img</code>. A DOL starts with a <code>0x100</code>-byte header:</p>
+
+<pre>0x00 u32 text_offset[7] 0x48 u32 text_address[7] 0x90 u32 text_size[7]
+0x1C u32 data_offset[11] 0x64 u32 data_address[11] 0xAC u32 data_size[11]
+0xD8 u32 bss_address 0xDC u32 bss_size 0xE0 u32 entry_point</pre>
+
+<p>To read virtual address <em>va</em>: find the section with
+<code>address ≤ va &lt; address + size</code>, then
+<code>file_offset = dol_offset + section_offset + (va − address)</code>. The GAFE01 rev 0
+DOL's populated sections:</p>
+
+<table>
+<tr><th>Section</th><th>File offset (in DOL)</th><th>Virtual address</th><th>Size</th></tr>
+<tr><td>text0</td><td><code>0x000100</code></td><td><code>0x80003100</code></td><td><code>0x02520</code></td></tr>
+<tr><td>text1</td><td><code>0x002620</code></td><td><code>0x800056C0</code></td><td><code>0xA40C0</code></td></tr>
+<tr><td>data0</td><td><code>0x0A66E0</code></td><td><code>0x80005620</code></td><td><code>0x00040</code></td></tr>
+<tr><td>data1</td><td><code>0x0A6720</code></td><td><code>0x80005660</code></td><td><code>0x00060</code></td></tr>
+<tr><td>data2</td><td><code>0x0A6780</code></td><td><code>0x800A9780</code></td><td><code>0x00040</code></td></tr>
+<tr><td>data3</td><td><code>0x0A67C0</code></td><td><code>0x800A97C0</code></td><td><code>0x00020</code></td></tr>
+<tr><td>data4</td><td><code>0x0A67E0</code></td><td><code>0x800A97E0</code></td><td><code>0x06080</code></td></tr>
+<tr><td>data5</td><td><code>0x0AC860</code></td><td><code>0x800AF860</code></td><td><code>0x32A20</code></td></tr>
+<tr><td>data6</td><td><code>0x0DF280</code></td><td><code>0x80217B80</code></td><td><code>0x00700</code></td></tr>
+<tr><td>data7</td><td><code>0x0DF980</code></td><td><code>0x80218BE0</code></td><td><code>0x00B40</code></td></tr>
+</table>
+
+<p>The five tables all live in <code>data5</code> (addresses from the ac-decomp symbol
+map for GAFE01 rev 0; other revisions have different addresses):</p>
+
+<table>
+<tr><th>Symbol</th><th>Virtual address</th><th>Size</th><th>Entries</th><th>Disc offset</th></tr>
+<tr><td><code>AudioseqHeaderStart</code></td><td><code>0x800CCA40</code></td><td><code>0xFA0</code></td><td>249</td><td><code>0x0E7A40</code></td></tr>
+<tr><td><code>AudiobankHeaderStart</code></td><td><code>0x800CD9E0</code></td><td><code>0xA00</code></td><td>159</td><td><code>0x0E89E0</code></td></tr>
+<tr><td><code>AudiowaveHeaderStart</code></td><td><code>0x800CE3E0</code></td><td><code>0x70</code></td><td>6</td><td><code>0x0E93E0</code></td></tr>
+<tr><td><code>AudiodataHeaderStart</code></td><td><code>0x800CE450</code></td><td><code>0x40</code></td><td>3</td><td><code>0x0E9450</code></td></tr>
+<tr><td><code>AudiomapHeaderStart</code></td><td><code>0x800CE490</code></td><td><code>0x3F0</code></td><td>—</td><td><code>0x0E9490</code></td></tr>
+</table>
+
+<p>"Disc offset" is the absolute image offset:
+<code>0x1E000 + 0xAC860 + (va − 0x800AF860)</code>. The
+<code>ArcHeader</code>/<code>ArcEntry</code> formats, the meaning of
+<code>param0/1/2</code>, and the zero-size alias-entry rule are on
+<a href="soundfont.html">the soundfont page</a>. An alternative to parsing the DOL is to
+hardcode the table contents — they are plain data in
+<code>audioheaders.c</code> of the decomp — but reading them from the image keeps the
+tool self-contained and self-checking.</p>
+
+<h2>4. audiorom.img</h2>
+
+<p><code>AudiodataHeaderStart</code> carves the file into three regions. Their sizes sum
+to exactly the file size:</p>
+
+<table>
+<tr><th>Region</th><th>Offset in file</th><th>Size</th><th>Absolute disc offset</th></tr>
+<tr><td>Audioseq</td><td><code>0x000000</code></td><td><code>0x0CF700</code></td><td><code>0x626D10</code></td></tr>
+<tr><td>Audiobank</td><td><code>0x0CF700</code></td><td><code>0x067C80</code></td><td><code>0x6F6410</code></td></tr>
+<tr><td>Audiowave</td><td><code>0x137380</code></td><td><code>0x6B33E0</code></td><td><code>0x75E090</code></td></tr>
+</table>
+
+<p>Entry addresses in the sequence, bank and wave tables are offsets into their region.
+The animalese entries:</p>
+
+<table>
+<tr><th>Entry</th><th>Region offset</th><th>Size</th><th>Absolute disc offset</th><th>param0</th><th>param1</th></tr>
+<tr><td>seq 243</td><td><code>0x0ABA60</code></td><td><code>0x660</code></td><td><code>0x6D2770</code></td><td></td><td></td></tr>
+<tr><td>seq 244</td><td><code>0x0AC0C0</code></td><td><code>0x480</code></td><td><code>0x6D2DD0</code></td><td></td><td></td></tr>
+<tr><td>seq 245</td><td><code>0x0AC540</code></td><td><code>0x660</code></td><td><code>0x6D3250</code></td><td></td><td></td></tr>
+<tr><td>bank 156</td><td><code>0x05C260</code></td><td><code>0x4500</code></td><td><code>0x752670</code></td><td><code>0x05FF</code></td><td><code>0x7700</code></td></tr>
+<tr><td>bank 157</td><td><code>0x060760</code></td><td><code>0x3020</code></td><td><code>0x756B70</code></td><td><code>0x05FF</code></td><td><code>0x5300</code></td></tr>
+<tr><td>bank 158</td><td><code>0x063780</code></td><td><code>0x4500</code></td><td><code>0x759B90</code></td><td><code>0x05FF</code></td><td><code>0x7700</code></td></tr>
+<tr><td>wave bank 5</td><td><code>0x2C5DC0</code></td><td><code>0x3ED620</code></td><td><code>0xA23E50</code></td><td></td><td></td></tr>
+</table>
+
+<h2>5. Walking a bank blob</h2>
+
+<p>The structures are defined on <a href="soundfont.html">the soundfont page</a>; this is
+the walk, with bank 156's real numbers. All internal fields are offsets
+<strong>relative to the bank blob's start</strong>, except <code>sample</code>, which is
+relative to the <strong>wave bank's start</strong>.</p>
+
+<pre>bank 156 @ disc 0x752670
+├ u32 drumListOffset = 0
+├ u32 sfxListOffset = 0
+├ u32 instrumentOffset[119] count from param1 >> 8
+│ [0] = 0x3610
+│ [1] = 0x3630 voicetables are 0x20 apart
+│ …
+│ [118] = 0x44D0
+└ instrument 0 (phoneme code 0x01, short a) @ +0x3610:
+ rel=0 range=[0,127] adsr_decay_idx=250 envelope=0x3600
+ low = { 0, 0.0 } unused
+ norm = { wavetable 0x01F0, tuning 0.689062 }
+ high = { 0, 0.0 } unused
+ wavetable @ +0x1F0:
+ codec=0 (VADPCM) medium=0 size=0x750
+ sample = 0x341130 → disc 0xA23E50 + 0x341130 = 0xD64F80
+ loop = 0x250 → { start=0, end=3328, count=0, sample_end=0 }
+ book = 0x200 → { order=2, n_predictors=2, s16 codebook[32] }
+ envelope @ +0x3600: (1, 0) (1, 32700) (-1, 0) (0, 0)</pre>
+
+<p>Facts that hold across every instrument in all three voice banks, verified by
+exhaustive scan:</p>
+
+<ul>
+ <li>Only the <em>normal</em> wtstr slot is populated; low/high are null.</li>
+ <li><code>tuning</code> is always <code>0.689062</code> = 22050 / 32000 — the recordings
+ are 22050&nbsp;Hz and the mixer runs at 32000&nbsp;Hz.</li>
+ <li><code>codec</code> = 0 (VADPCM), <code>order</code> = 2,
+ <code>n_predictors</code> = 2, <code>loop.count</code> = 0 (no looping);
+ <code>loop.end</code> equals the decoded sample count.</li>
+ <li>Every instrument shares the same envelope: <code>(1,0) (1,32700) (-1,0)</code> —
+ one envelope step at zero, one step rising to 32700/32768, then hang
+ (<code>-1</code> = <code>ADSR_HANG</code>) until the note is released, then fade at
+ <code>adsr_decay_table[250]</code>. Effectively: instant attack, full sustain,
+ fast release.</li>
+ <li>Durations run 32–407&nbsp;ms (mean ≈ 189&nbsp;ms). The longest samples are the
+ editor's letter-name block — "zero" at 407&nbsp;ms is a whole word.</li>
+ <li>The three banks' samples tile wave bank 5 contiguously and share nothing:
+ 156 owns <code>0x341130–0x385890</code>, 157 <code>0x385890–0x3AAAAC</code>,
+ 158 <code>0x3AAAB0–0x3ED612</code> — each voice is a separate recording session.
+ (This is also what pins <code>sample</code> as wave-bank-relative: bank 158's last
+ sample ends 14 bytes short of the wave bank's exact end, and treating the offsets
+ as region-relative lands mid-stream in invalid VADPCM.)</li>
+</ul>
+
+<h2>6. Decoding VADPCM</h2>
+
+<p>Each frame is 9 bytes → 16 samples. Total samples = <code>size / 9 × 16</code>.</p>
+
+<pre>byte 0: scale = header >> 4 (0–12 in these banks)
+ predictor = header & 0xF (0 or 1)
+bytes 1–8: 16 nibbles, high first — sign-extend each from 4 bits,
+ then residual r[i] = nibble << scale</pre>
+
+<p>The codebook is stored as <code>order × 8</code> signed 16-bit coefficients per
+predictor. Expand each predictor into an 8-row × (order+8)-column matrix
+<code>M</code>:</p>
+
+<pre>M[row][col] for col &lt; order: book[predictor][col*8 + row]
+M[row][order + i] = 2048 if i == row
+ = M[row − i − 1][order−1] if i &lt; row
+ = 0 if i &gt; row</pre>
+
+<p>Decode two 8-sample halves per frame. Each half takes an input vector of the previous
+<code>order</code> (= 2) <em>decoded</em> samples followed by that half's 8
+<em>scaled residuals</em>:</p>
+
+<pre>in = [ out[-2], out[-1], r[0..7] ]
+out[row] = floor( Σ M[row][k] · in[k] / 2048 ) for row 0..7</pre>
+
+<p>Initial state is two zero samples; state carries across frames for the length of the
+sample. The division is a <strong>floor</strong> (round toward −∞), not truncation.</p>
+
+<div class="note">
+<strong>The one easy mistake</strong> is feeding decoded outputs back per-sample inside a
+half — i.e. running <code>out[n] = r[n] + (c₁·out[n−1] + c₂·out[n−2]) / 2048</code>
+with rounding at every step. The matrix form convolves the <em>scaled residuals</em>
+and rounds once per output sample; cascading the rounding decodes audibly noisy audio.
+</div>
+
+<p>Validation for this exact algorithm against this exact image: instrument 0 of bank
+156 decodes to 3,328 samples (equal to its <code>loop.end</code>), peak amplitude 19,213,
+nothing out of s16 range, and a smooth vowel envelope.</p>
+
+<h2>7. The voice sequences, disassembled</h2>
+
+<p>Sequences 243/244/245 are tiny programs in the JAudio-NES (ported Zelda 64) sequence
+bytecode. They are structurally identical; byte-for-byte they differ only in one
+handshake constant and in their note/timing parameters. Sequence 243 annotated:</p>
+
+<pre>; ---- group (master) script @ 0x00 ----
+0x00 db 7f master volume 127
+0x02 dd 78 tempo 120 BPM
+0x04 d7 00 03 enable subtracks 0 and 1
+0x07 90 00 13 start subtrack 0 at 0x13
+0x0A 91 00 13 start subtrack 1 at 0x13 ; same program, separate state
+0x0D fd fd 00 delay 0x7D00 ticks
+0x10 fb 00 0d branch → 0x0D ; idle forever
+
+; ---- subtrack (channel) program @ 0x13, shared by both ----
+0x13 e9 88 SET_PRIORITY 0x88
+0x15 c4 LARGE_NOTE_ON
+0x16 dc 7f SET_PAN_WEIGHT 0x7F
+0x18 ec RESET_VIBRATO
+loop:
+0x19 fe delay 1 tick
+0x1A cc 01 MACRO_SET: value ← 1 ; 2 in seq 244, 3 in seq 245
+0x1C 71 PORT1_WRITE_MACRO_REG ; port[1] ← value (see below)
+0x1D 60 MACRO_READ_PORT0 ; value ← port[0]; port[0] ← -1
+0x1E f2 f9 branch → loop if value &lt; 0 ; port empty
+0x20 c2 00 26 SET_DYNTBL 0x0026
+0x23 e4 DYNTBL_CALL ; call dyntbl[value] (skips -1)
+0x24 f4 f3 branch → loop
+
+; ---- dyntbl @ 0x26: 128 big-endian u16 script addresses ----
+; entry v is the routine for phoneme code v
+
+; ---- per-phoneme routine (codes 0x01–0x52, 10 bytes each) ----
+c1 &lt;inst&gt; VOICE_SET: instrument = code − 1
+88 &lt;addr16&gt; NOTE0_SET_PC: start note layer at addr
+ff return to the spin loop
+; layer:
+&lt;note&gt; &lt;delay&gt; &lt;velocity&gt; ff ; e.g. 6c 0d 6e ff</pre>
+
+<p>Semantics that matter for a reimplementation:</p>
+
+<ul>
+ <li><strong>The port-1 handshake.</strong> Every iteration the subtrack publishes its
+ identity (1/2/3) to its port 1. This is what
+ <code>Nap_ReadSubPort(VOICE_GROUP, 0, 1) == sou_now_voice_seq</code> polls — the
+ load-race gate on <a href="gating.html">the gating page</a>. It is the
+ <em>only</em> byte that differs between the three sequences' programs.</li>
+ <li><strong>Port reads are destructive.</strong> <code>MACRO_READ_PORT0</code> returns
+ the value and resets the port to −1, and both the branch (<code>0xF2</code> branches
+ on negative) and <code>DYNTBL_CALL</code> (skips −1) treat −1 as "empty". So each
+ <code>Sou_VoiceStart</code> write triggers exactly one routine, at most one per
+ tick per subtrack.</li>
+ <li><strong>Note command decode</strong> (large-note mode, opcode &lt; 0xC0):
+ commands <code>0x40–0x7F</code> are note-on with
+ <code>semitone = opcode − 0x40</code>, then a varint delay and a velocity byte,
+ gate&nbsp;0. Volume contribution is <code>(velocity / 127)²</code>.
+ <span class="src"><code>internal/track.c:1028 __SetNote</code></span></li>
+ <li><strong>Pitch.</strong> <code>frequency_scale = PITCHTABLE[semitone] ×
+ tuning</code>, where <code>PITCHTABLE[i] = 2^((i − 48) / 12)</code> — unity at 48.
+ <span class="src"><code>internal/audiotable.c:321</code>,
+ <code>internal/track.c:975</code></span></li>
+ <li><strong>Ticks.</strong> One sequence tick ≈ 9.9&nbsp;ms: ticks fire at
+ <code>240 updates/s × tempo / max_tempo</code> with
+ <code>tempo = 120 × 48 = 5760</code> and <code>max_tempo = 13726</code>
+ (from <code>60000 × 48 × 4 / 48 / 16.713 / 1.04613</code>,
+ <code>internal/memory.c:998</code>) — ≈ 100.7 ticks/s. When the layer's delay
+ expires the note releases through <code>adsr_decay_table[250]</code>.</li>
+</ul>
+
+<h3>The complete per-code parameter map</h3>
+
+<p>Extracted from the shipped sequences. "Semitone" is relative to unity at 48, so 44
+plays a sample at 2^(−4/12) ≈ 0.794× recorded speed, 41 at 0.667×, 39 at 0.595× —
+each bank's recordings are tuned differently, which is part of why the three voices
+sound distinct.</p>
+
+<table>
+<tr><th>Seq</th><th>Codes</th><th>Instrument</th><th>Semitone</th><th>Delay (ticks)</th><th>Velocity</th><th>Extras</th></tr>
+<tr><td rowspan="5">243</td>
+ <td><code>0x01–0x38</code></td><td>code − 1</td><td>44</td><td>13</td><td>110</td><td></td></tr>
+<tr><td><code>0x39–0x52</code></td><td>code − 1</td><td>44</td><td>15</td><td>110</td><td></td></tr>
+<tr><td><code>0x53–0x76</code></td><td>code − 1</td><td>44</td><td>28</td><td>95</td><td>pitch sweep from semitone 41 (portamento <code>c7 81 29 ff</code>)</td></tr>
+<tr><td><code>0x77</code></td><td>0x76</td><td>44</td><td>20</td><td>110</td><td></td></tr>
+<tr><td><code>0x78–0x7F</code></td><td colspan="4">silence (routine is an immediate <code>ff</code>)</td><td></td></tr>
+<tr><td rowspan="4">244</td>
+ <td><code>0x01–0x38</code></td><td>code − 1</td><td>41</td><td>16</td><td>110</td><td></td></tr>
+<tr><td><code>0x39–0x52</code></td><td>code − 1</td><td>39</td><td>20</td><td>110</td><td></td></tr>
+<tr><td><code>0x77</code></td><td><strong>0x52</strong></td><td>41</td><td>20</td><td>110</td><td>the <code>you</code> substitute — see below</td></tr>
+<tr><td><code>0x53–0x76</code>, <code>0x78–0x7F</code></td><td colspan="4">silence</td><td></td></tr>
+<tr><td rowspan="4">245</td>
+ <td><code>0x01–0x38</code></td><td>code − 1</td><td>39</td><td>17</td><td>110</td><td></td></tr>
+<tr><td><code>0x39–0x52</code>, <code>0x77</code></td><td>code − 1</td><td>39</td><td>20</td><td>110</td><td></td></tr>
+<tr><td><code>0x53–0x76</code></td><td>code − 1</td><td>44</td><td>28</td><td>95</td><td>pitch sweep from semitone 41</td></tr>
+<tr><td><code>0x78–0x7F</code></td><td colspan="4">silence</td><td></td></tr>
+</table>
+
+<p>Two things this table reveals that the C code cannot:</p>
+
+<ul>
+ <li><strong>The editor letter names have a rising intonation.</strong> Codes
+ <code>0x53–0x76</code> play with portamento mode 1 sweeping from semitone 41 up to
+ 44, longer (28 ticks) and quieter (velocity 95) than dialogue phonemes — each
+ keystroke's letter name lilts upward.</li>
+ <li><strong>Spec 3's <code>you</code> is deliberate, not clamped.</strong> Sequence
+ 244 maps code <code>0x77</code> to instrument <code>0x52</code> — bank 157's 83rd
+ and last instrument, which sits <em>above</em> the phonology range
+ (<code>0x01–0x52</code>) and is reachable by nothing else. Bank 157 carries one
+ extra instrument specifically to voice <code>you</code>.</li>
+</ul>
+
+<h2>8. Reassembling audible animalese</h2>
+
+<p>For one phoneme code <em>v</em> spoken by spec <em>s</em>:</p>
+
+<pre>seq, bank = spec table (page 1) ; 243/156, 244/157, 245/158
+inst, semitone, delay, velocity = per-code map above (dyntbl[v])
+pcm = vadpcm_decode(bank.instrument[inst].normal.sample)
+rate = 32000 × tuning × 2^((semitone − 48)/12)
+ × spec_pitch × emotion_pitch × punctuation_pitch ; pages 1 and 5
+gain = (velocity/127)² × master(127/127) × spec_vol × emotion_vol
+env = near-instant attack to 32700/32768, hold,
+ release after `delay` ticks via adsr_decay_table[250]
+pan = centre, no reverb, no distance attenuation</pre>
+
+<p>Practically: decoding the sample and playing it at
+<code>22050 × 2^((semitone − 48 + 12·log₂(pitch_mults))/12)</code>&nbsp;Hz with a
+<code>(velocity/127)²</code> gain gets within a hair of the real thing; the envelope
+release only matters when a sample outlasts its note delay. Timing of successive
+syllables comes from the <a href="scheduling.html">scheduler</a>, not from the audio
+engine — new syllables do not cut old ones off, they land on the alternating other
+subtrack and overlap.</p>
+
+<div class="note">
+<strong>Scope.</strong> These offsets are for the USA rev 0 image (<code>GAFE01</code>,
+the one ac-decomp matches). The FST walk, DOL walk and all structure layouts are
+revision-independent; the DOL virtual addresses and disc offsets are not.
+</div>
+
+<div class="note">
+<strong>NKit-shrunk images work unmodified.</strong> An NKit-shrunk rip (marker
+<code>"NKIT v01"</code> at disc offset <code>0x200</code>; original size as a big-endian
+u32 at <code>0x210</code>) strips the ~1.4&nbsp;GB of junk padding <em>after</em> the
+real data without relocating it — and Animal Crossing's 11 files all end by
+<code>0xE11470</code>, so every offset on this page still reads valid data (verified
+against such an image). Two rules keep a loader safe on any variant: never derive
+anything from total file size, and never read past the extents the FST declares.
+</div>
+
+<div class="pager">
+ <span><a href="editor.html">← 7. The editor path</a></span>
+ <span><a href="quirks.html">9. Quirks &amp; corrections →</a></span>
+</div>
+
+</body>
+</html>