| decomp | ||
| ghidra_scripts | ||
| gproj | ||
| cvrdec.py | ||
| cvrtool.py | ||
| dlu.py | ||
| find_packing.py | ||
| g723_d24.py | ||
| identify.py | ||
| invert.py | ||
| matchref.py | ||
| README.md | ||
| scan_offsets.py | ||
| search_interleave.py | ||
| sweep.py | ||
CVR File Decompression — Reverse-Engineering Findings
Sessions: 2026-08-05, 2026-08-28, 2026-08-30.
Working dir: /home/retorikal/Downloads/K01/.
Goal: reimplement the Win 3.1 CVR decompressor (PLAYBACK.EXE + SSCVRDLL.DLL) as a modern
standalone tool so the pipeline no longer depends on running the 1996 binary under otvdm/Wine.
STATUS: format fully reversed; every region verified bit-exact
A working decoder lives in cvrtool/. Every audio output of both example downloads —
CH1, CH2, CH3, CH4, FP and MP — has been matched bit-exactly against its reference WAV,
including full-length runs:
$ cd cvrtool
$ python3 cvrtool.py ../Examples/Kasus_02/DOWNLD25.dlu -r wb -d 120 \
--verify ../Examples/Kasus_02/ZS-SJU_FP.WAV
VERIFY OK: bit-exact over 1,920,000 samples
$ python3 matchref.py # lag-aligned check of every region vs every reference
Full-length run, DOWNLD01.dlu nb3 channel 1 vs WAV1.WAV — the entire 30-minute
recording, zero mismatches:
decoded FULL nb3 CH1: 14,558,464 samples (1819.8s), 62,752 markers, 370 control records
reference WAV1: 14,558,560 samples; compared 14,558,464 at lag +96
mismatches: 0 (0.000000%)
Full-length channel 4, both examples, at lag 0 (2026-08-30):
DOWNLD01 CH4: 29,116,928 samples, 62,752 markers, 374 control records — BIT-EXACT vs WAV4.WAV
DOWNLD25 CH4: 29,007,424 samples, 62,516 markers, 474 control records — BIT-EXACT vs ZS-SJU_CH4.WAV
$ python3 cvrtool.py ../Examples/Kasus_02/DOWNLD25.dlu -r ch4 -d 120 \
--verify ../Examples/Kasus_02/ZS-SJU_CH4.WAV
VERIFY OK: bit-exact over 1,920,000 samples
$ python3 cvrtool.py ../Examples/Kasus_02/DOWNLD25.dlu -o out/ # all outputs
$ python3 cvrtool.py ../Examples/Kasus_01/DOWNLD01.dlu -o out/ -d 20 # first 20 s
Use
matchref.py, not a plain offset-0 diff, to compare against a reference. PLAYBACK's WAV output starts a short lead-in earlier than the first complete frame in a region (+96 samples for Kasus_01, +72 for Kasus_02), so comparing at offset 0 shows a total mismatch even when the decode is perfect. See §5.
1. Big picture
| File | Role | Format | Reversed? |
|---|---|---|---|
PLAYBACK.EXE |
UI + container demuxer + orchestrator | Win16 NE EXE, 83 KB, 1996-11-20 | Decode path fully reversed |
SSCVRDLL.DLL |
3-bit ADPCM decoder | Win16 NE DLL, 10 KB, 1993-08-12, SELFLOAD | Fully reversed + ported |
MMMIXER.DLL |
Audio mixer support | Win16 NE DLL, 22 KB, 1995 | Not needed |
METER.DLL |
Level meter | Win16 NE DLL, 9 KB, 1992 | Not needed |
COMMDLG.DLL |
Common dialogs (bundled copy) | Win16 NE DLL, 33 KB, 2016 mtime | Not needed |
MMMIXER.DLL / METER.DLL turned out to be playback-UI only — no codec code. Nothing in
either is needed to decode a file.
2. DLU2 File Format
Header size is exactly 620 bytes (0x26C). Chunk-based, RIFF-style but with fixed order.
offset 0x000 : "DLU2" magic (4 B)
offset 0x004 : uint32 LE total header size (= 0x26C = 620)
offset 0x008 : "info" + u32 size=0x200
body: 512 bytes
offset 0x010 : u32 = channel count (4 in Kasus_02, 0 in Kasus_01)
offset 0x014 : "0103" ASCII (format/version code; zeros in Kasus_01)
remainder : zero padding
offset 0x210 : "date" + u32 size=6 + u16[3] (day, month, year LE)
offset 0x21E : "time" + u32 size=6 + u16[3] (hour, minute, second LE)
offset 0x22C : "nb3 " + u32 size=8 + (u32 start, u32 length) ← narrow band, 3 channels
offset 0x23C : "wb " + u32 size=8 + (u32 start, u32 length) ← wide band
offset 0x24C : "mb " + u32 size=8 + (u32 start, u32 length) ← mid band
offset 0x25C : "ee " + u32 size=8 + (u32 start, u32 length) ← "SSCVR EEPROM" dump
offset 0x26C : data region starts
start and length are relative to file offset 620. The four regions are strictly
contiguous. ee is an 8192-byte device EEPROM dump (first 13 bytes
\xC0"SSCVR EEPROM"\x00...) — non-audio.
Parser: cvrtool/dlu.py. python3 dlu.py FILE... dumps the chunk table for .dlu
files and the format block for .wav files.
Region → output mapping (confirmed)
| Region | Contents | Rate | Frame period | Payload | Samples/frame |
|---|---|---|---|---|---|
nb3 |
3 channels | 8 kHz | 262 B | 261 B = 29 × 9 B | 232 per channel |
wb |
1 channel | 16 kHz | 175 B | 174 B = 29 × 6 B | 464 |
mb |
1 channel | 8 kHz | 88 B | 87 B = 29 × 3 B | 232 |
Every frame is 29 groups, whatever the band. The 0x105 / 0xae / 0x57 (261 / 174 / 87)
payload constants are literals in PLAYBACK.EXE!FUN_1000_26e4.
PATS*.PCM is not a separate format
Kasus_02/PATS25.PCM has an 8-byte magic "DLU2loda" followed by four 16-byte chunk
entries (tee , wb , mb , ee ), so its header is 72 bytes (0x48) instead of 620.
The chunk start/length values and the payload bytes from 0x48 onward are identical to
DOWNLD25.dlu. It is the same data with a shorter header — useful only as a checkpoint.
(tee is the nb3 region; same length, 16,396,756.)
PATS*.DAT is the demuxer's log — identified, not needed
PLAYBACK.EXE!FUN_1000_26e4 writes control records plus a formatted timestamp and a
\x0D\x0A to a third output file. That is PATS*.DAT. It carries no audio.
GCD files are a different format
Kasus_B01/*.gcd and Kasus_B02/*.gcd start with FF FF FF FF, not DLU2. Kasus_B01
ships the same bytes under both .gcd and .DLU, so PLAYBACK dispatches on magic, not
extension. Still un-reversed (see §7).
3. SSCVRDLL.DLL Codec
This is standard Standard ITU-T G.723-24.
Table verification
| Fn | Role | DLL values | ITU / Sun reference |
|---|---|---|---|
0x17ee |
dqlntab |
0x800, 0x87, 0x111, 0x175 |
−2048, 135, 273, 373 ✓ |
0x149e |
witab/32 |
0xffc, 0x1e, 0x89, 0x246 |
×32 → −128, 960, 4384, 18624 ✓ |
0x1468 |
fitab/512 |
0, 1, 2, 7 |
×512 → 0, 0x200, 0x400, 0xE00 ✓ |
0x1798 |
qtab_723_24 |
8 / 0xd9,0xda / 0x14a,0x14b |
8, 218, 331 ✓ |
0x150c |
LIMB | 0x220 … 0x1400 |
clamp yu to 544 … 5120 ✓ |
0x1548 |
LIMC (UPA2) | ±0x3000 |
clamp a2 to ±12288 ✓ |
0x1576 |
LIMD (UPA1) | 0x3c00 − a2 |
clamp a1 to ±(15360 − a2) ✓ |
0x1cf6 |
reset | yu=0x220, yl=0x8800, dq/sr=0x20 |
544, 34816 = 544≪6, float-zero ✓ |
Function map (all in cvrtool/decomp/sscvrdll.c, ported in cvrtool/g723_d24.py):
0x0822 G723_D24 (export #2) 0x0d38 ADDA 0x0d7a ANTILOG 0x0d4a ADDB
0x0dce ulaw2linear 0x1b78 linear2ulaw 0x0e20 FILTA 0x0e82 FILTB
0x0ee4 FILTC 0x0f3c FILTD 0x0f94 FILTE 0x0ffc FLOATB
0x1144 FLOATA 0x129c FMULT 0x14ee al 0x16f2 step_size
0x15b6 LOG 0x183e dx 0x186a dln 0x187e SUBTC
0x18ca TONE 0x18ea TRANS 0x1988 UPA1 0x19d4 UPA2
0x1b18 UPB 0x1b64 sign XOR 0x1bf6 tandem_adjust_ulaw
0x1cf6 reset (idle code 0xE0) 0x0db6 delay-line TRANS reset
Quirks
DAT_11df_00a0— the TRANS reset flag for thedq/srdelay line — is never written. Only0x1cf6(reset) zeroes it. So helper0x0db6always passes the previous value through and the delay-line reset on transition is dead code, unlike the reference implementation.0x18ea(TRANS) clamps atylint >= 9to31<<9(0x3e00); the reference clamps atylint > 9to31<<10.DAT_11df_0012— the tandem-adjusted µ-law codeword — is computed and stored but never read. The returned sample is plainlyulaw2linear(linear2ulaw(sr)) << 2, i.e. the tandem adjustment is discarded. (This is why every output sample is a µ-law reconstruction level × 4: observed WAV values are all multiples of 8.)
Output is 14-bit µ-law-quantised, shifted left 2 to fill 16 bits.
G723_D24(0xE0) resets the state and returns 0.
4. Decode pipeline
The data region is raw 3-bit codec data packed and interleaved (if multiple channels), then added with some control markers scattered throughout.
The 2 mentioned process must be reversed before the underlying 3-bit codec data can be consumed by the g723-24 decoder.
The full decode process looks like the following:
[RAW DATA ..................... EOF]
│
│ LOCATE DATA REGION (§2)
| Jump according to header's region bytes (`nb3 ` / `wb ` / `mb `)
▼
[40] [payload frame (261 B)] [40] [payload frame (261 B)] [03 xx xx xx] [40] […]
▲ ▲ ▲
marker byte marker byte control record
│
│ CONTROL BYTE STRIPPING (§4.2):
| at every GROUP BOUNDARY, read 1 byte and process on what is it:
| - Marker bytes [40] or [20] : discard
| - Control record [00] to [07] : discard, exact byte tells this group's length
| - End of stream record [80] : stop processing
| - Anything else : preserve
▼
[payload frame (261 B) ............] [payload frame (261 B) ............]
which is equivalent to:
[group 0 (9B)] [...] [group 28 (9B)] [group 0 (9B)] [...] [group 28 (9B)]
note: Illustration above is for nb3. On wb a group is 6B, on mb it's 3B
│
│ UNGROUPING (§4.4)
| for each GROUP, perform MSB-first 3-bit unpack, then de-interleave per mode
| bit index 0 1 2 3 4 5 6 7 8 ... 21 22 23
| channel 1 2 3 1 2 3 1 2 3 ... 1 2 3
| ▲ ▲ ▲ ▲
| └───────────┴───────────┴───── … ───────┘
| mode 1 = stride 3, phase 0 → 8 codes, all channel 1
▼
[ch1 sample 0 (3b)] [ch1 sample 1 (3b)] [...] [ch1 sample 7 (3b)]
[ch2 sample 0 (3b)] [ch2 sample 1 (3b)] [...] [ch2 sample 7 (3b)]
[ch3 sample 0 (3b)] [ch3 sample 1 (3b)] [...] [ch3 sample 7 (3b)]
note: Illustration above is for nb3. wb and mb only produces 1 channel.
│
│ CODEC (§3)
| for each SAMPLE (3b), feed to G.723-24 to reconstruct the audio sample (16b)
▼
[16-bit sample]
wb and mb differ only in the constants: their groups are 6 B / 3 B and hold a
single channel, so the group layer unpacks them contiguously (stride 1) instead of
de-interleaving. Frame geometry per band is tabulated in §2; modes in §4.4.
Authoritative sources: PLAYBACK.EXE!FUN_1000_3a58 (decode loop), FUN_1000_3f5a
(de-interleaver), FUN_1000_3272 (output selection), FUN_1000_26e4 (raw demuxer).
Reference implementation: cvrtool/cvrdec.py.
4.1 Terms
| Term | Definition |
|---|---|
| Region | A nb3 / wb / mb extent of the container, as located by the header (§2). |
| Frame | One marker byte followed by a fixed-size payload, optionally followed by control records. |
| Period | Marker-to-marker distance in the absence of control records: 262 / 175 / 88 B. |
| Payload | period − 1 bytes: 261 / 174 / 87 B, always exactly 29 groups. |
| Group | The unit of bit unpacking: 9 B (nb3 ), 6 B (wb ) or 3 B (mb ). |
| Mode | Selector 1–6 choosing group size, unpacking stride and phase (§4.4). |
The payload constants 0x105 / 0xae / 0x57 (261 / 174 / 87) are literals in
FUN_1000_26e4.
4.2 Byte layer
A decoder maintains a byte cursor into the region. At every group boundary it reads one byte and dispatches on its value before attempting to unpack a group:
| Byte | Class | Required action |
|---|---|---|
marker (0x40, or 0x20 — §4.3) |
Frame marker | Discard the byte; advance 1; re-dispatch. |
0x00–0x07 |
Control record | Discard the whole record (see length table); advance by its length; re-dispatch. |
0x80 |
End of stream | Stop decoding the region. |
| any other value | Group data | Consume group_size bytes and unpack (§4.4). |
Control record lengths, taken from the switch in FUN_1000_26e4 — it skips iVar2 − 1
bytes after the lead byte, so total length is iVar2; 0x07 falls outside the switch and
consumes only itself:
| Lead byte | 0x00 |
0x01 |
0x02 |
0x03 |
0x04 |
0x05 |
0x06 |
0x07 |
|---|---|---|---|---|---|---|---|---|
| Record length (bytes) | 6 | 5 | 6 | 4 | 1 | 1 | 1 | 1 |
Control records occur only at frame boundaries and account for all anomalous marker gaps
(+4 / +6 / +10 instead of the nominal period). A real occurrence from wb , immediately
preceding a marker: 00 16 b4 ce e5 00.
Skipping control records is mandatory. A decoder that ignores them stays bit-exact for
about 16 frames and then desynchronises permanently. Roughly 7% of nb3 frames carry one.
It is currently unclear what is the data behind a control record is used for.
4.3 Marker selection
The marker value is fixed per download by header flag DAT_1e77_31b8:
DAT_1e77_31b8 |
Marker byte |
|---|---|
| 1 | 0x20 |
| otherwise | 0x40 |
(local_29 at PLAYBACK.EXE:1833/1846.) Both example downloads use 0x40; the nb3
region of DOWNLD25.dlu contains 62,516 × 0x40 against 8 incidental 0x20 bytes — all 8
inside control records, none in payload, as the preceding subsection requires.
cvrtool.py --marker 0x20 selects the other case, but there currently is no sample that uses 0x20 as the main marker. (§5, §7).
4.4 Ungrouping
Every layout is a plain MSB-first 3-bit packing of the group's bits. The mode selects only the group size, the stride and the phase; there is no per-mode bit reordering.
| Mode | Group size | Codes emitted | Stride | Phase | Channel |
|---|---|---|---|---|---|
| 1 | 9 B (24 codes) | 8 | 3 | 0 | nb3 channel 1 |
| 2 | 9 B | 8 | 3 | 1 | nb3 channel 2 |
| 3 | 9 B | 8 | 3 | 2 | nb3 channel 3 |
| 4 | 6 B (48 bits) | 16 | 1 | 0 | wide band, one channel |
| 5 | 6 B | 16 | 1 | 0 | wide band, one channel (same packing as mode 4) |
| 6 | 3 B (24 bits) | 8 | 1 | 0 | mid band, one channel |
Modes 4 and 5 are identical unpacking code; they differ only in the seek offset they read.
Sample rate is an explicit 0x1f40 = 8000 for modes 1–3 and 6, and 16000 for modes 4 and 5
(the latter confirmed empirically against FP).
Conformance note: cvrdec._unpack_nb3 was checked against the literal decompiled
expressions of FUN_1000_3f5a over 2000 random 9-byte groups across all three phases, with
exact agreement. The check script was not retained; it is reconstructible from the
case 1/2/3 bodies in decomp/playback.c.
4.5 Output selection
FUN_1000_3272 switches on output index local_12 (0–5). Each output binds a mode, a seek
offset and a frame period:
| Output index | Name | Mode | Seek offset | Frame period | Rate |
|---|---|---|---|---|---|
| 0–2 | CH1 / CH2 / CH3 | 1 / 2 / 3 | DAT_1e77_3164 |
0x106 |
8 kHz |
| 3 | CH4 | 4 | DAT_1e77_3170 |
0xaf |
16 kHz |
| 4 | FP (whole wide band) | 5 | DAT_1e77_3168 |
0xaf |
16 kHz |
| 5 | MP | 6 | DAT_1e77_316c |
0x58 |
8 kHz |
The mode-4 / mode-5 distinction is therefore not two packings: it is the same 16 kHz
decode run twice over different parts of the wb region. cvrtool implements both
wide-band outputs with a single mode 4, varying only start offset and length. See §5 for
how the CH4 extent is derived.
4.6 Region entry point
A region does not begin on a frame boundary. Before decoding, a decoder must locate the
first complete frame by walking the stream exactly as the byte layer does — marker,
payload, then any control records — rather than probing at a fixed +period stride. A
stride-based search is unreliable: control records displace every subsequent frame, so on
nb3 it rejects the correct offset often enough to fail. cvrtool.py!find_first_marker
implements the walking search.
Observed offsets of the first complete frame, relative to region start:
| Region | Kasus_01 | Kasus_02 |
|---|---|---|
nb3 |
+47 | +16 |
wb |
+71 | +135 |
mb |
+66 | +50 |
4.7 Raw pre-demux stream (informative)
Not required to decode a .dlu file; recorded for completeness.
FUN_1000_26e4 demultiplexes a raw download into three per-band files. In that raw stream:
0x20and0x40both introduce frames, settingDAT_1d50to 1 or 2 respectively.- The current band is held in
DAT_1e77_1d4e(0/1/2 → 261/174/87-byte frames). - Bytes
0x00–0x07are the control records of §4.2. 0x80,0x81,0x82,0x83and0xa0raise a fatal message box.
The .dlu files in hand are already band-separated but still contain markers and control
records, which is why the byte layer of §4.2 remains necessary after demuxing.
5. Validation status
Run python3 matchref.py to reproduce. lag is how many samples PLAYBACK emitted before
our first complete frame; comparison is done at that lag.
| Source | Region / mode | Reference | Result |
|---|---|---|---|
DOWNLD01.dlu |
nb3 mode 1 |
WAV1.WAV |
lag +96, 0 mismatches in all 14,558,464 samples (full 30 min) |
DOWNLD01.dlu |
nb3 mode 2 |
WAV2.WAV |
lag +96, 2 differ in 400,000 (both at index 0) |
DOWNLD01.dlu |
nb3 mode 3 |
WAV3.WAV |
lag +96, bit-exact over 400,000 |
DOWNLD01.dlu |
wb mode 4 |
WAV4.WAV |
matches the last 30 min of the wide band (see below) |
DOWNLD25.dlu |
nb3 modes 1–3 |
ZS-SJU_CH1/2/3.WAV |
lag +72, bit-exact after a 4,813-sample (0.6 s) transient |
DOWNLD25.dlu |
wb mode 4 |
ZS-SJU_FP.WAV |
lag 0, bit-exact over 1,920,000 samples |
DOWNLD25.dlu |
mb mode 6 |
ZS-SJU_MP.WAV |
anchored at MP sample 25,763,136; bit-exact from sample 2,342 on |
DOWNLD01.dlu |
ch4 |
WAV4.WAV |
lag 0, bit-exact over all 29,116,928 samples |
DOWNLD25.dlu |
ch4 |
ZS-SJU_CH4.WAV |
lag 0, bit-exact over all 29,007,424 samples |
Notes
- PLAYBACK emits a lead-in. Its WAV starts before the first complete frame in the
region (+96 samples for Kasus_01, +72 for Kasus_02), so a comparison at offset 0 fails
completely. Worse, the match cannot be recovered by scanning start offsets within the
region: reproducing those 96 samples would mean starting 108 bytes before the region
begins. Those leading samples also contain a large startup transient (
WAV1's first 10 s reads rms 37 / max 6652 purely because of a couple of impulse samples at the very front), which inflates any windowed statistic computed from sample 0. - Restarting the codec mid-stream costs a short transient. When a comparison starts at an arbitrary frame rather than the true beginning, the adaptive predictor needs ≈2,000–5,000 samples to converge, after which output is bit-identical. Every non-zero mismatch count above is entirely confined to that leading window — verified by histogramming the mismatch indices.
CH4 / WAV4 are extracts, not regions — SOLVED, bit-exact
There is no separate 30-minute region in the file. Channel 4 is the tail of the wide band, decoded in a second pass with the codec reset at that point. This is the standard CVR arrangement: a 2-hour recording plus the final 30 minutes broken out separately.
The rule, and it is exact on both examples:
CH4 = the last K frames of
wb, where K is the number of frames innb3, decoded from a freshG723_D24(0xE0)reset at that frame.
| Kasus_01 | Kasus_02 | |
|---|---|---|
nb3 frames = K |
62,752 | 62,516 |
wb frames total |
250,625 | 250,611 |
| CH4 first frame index | 187,873 | 188,095 |
byte offset within wb |
32,886,930 | 32,925,541 |
| samples = K × 464 | 29,116,928 | 29,007,424 |
Those sample counts are exactly the lengths of WAV4.WAV and ZS-SJU_CH4.WAV, and the
decode is bit-exact from sample 0 — CH4 is the only output with no lead-in lag, because
unlike the other regions it genuinely starts on a frame boundary. cvrtool.py -r ch4
output is byte-identical to the legacy WAV apart from byte 41, the data chunk size,
where PLAYBACK writes 4 too many (§8):
$ cmp tmp/DOWNLD01_CH4.wav Examples/Kasus_01/WAV4.WAV
differ: byte 41, line 1 # the only difference in 58,233,900 bytes
Where this comes from in PLAYBACK.EXE: FUN_1000_3272 emits six outputs (local_12
0–5). Cases 0–2 are nb3 CH1–3, case 3 is CH4, case 4 is the full wide band (FP),
case 5 is mb. Case 3 uses frame size 0xaf (175, wide band) but reuses DAT_1e77_2e10,
the same length counter as cases 0–2 — that is why CH4 has the narrow band's duration.
Its seek offset is DAT_1e77_3170, computed in FUN_1000_2d0c.
Correction to the 2026-08-28 session. The note that
ZS-SJU_CH4.WAV == FP[87,276,080:]is nearly right — the offset is correct (frame 188,095) but the two are not equal: 2,235 of the first 4,747 samples differ, because PLAYBACK resets the codec when it starts CH4 while FP is decoded continuously through that point. The same shows up in Kasus_01 (1,600 differences within the first 3,358 samples). Slicing FP therefore does not reproduce CH4; decoding from the CH4 frame with a reset does, bit-exactly.
Older 0x20-marker downloads differ, untested. FUN_1000_2d0c copies the wide-band
seek offset into the CH4 slot verbatim when DAT_1e77_31b8 == 1 — the same flag that
selects the 0x20 frame marker (§4.3). On those downloads CH4 is the first K wide-band
frames, not the last. cvrtool.py --marker 0x20 implements this, but no 0x20 example
exists to confirm it against.
FUN_1000_2d0c's seek arithmetic — NOT reversed, and not needed
The frame-counting rule above is what is verified. PLAYBACK itself gets to the same place by byte arithmetic that has not been reconciled with the data. Recorded here so the next session does not repeat the dead ends.
The expression, from the DAT_1e77_31b8 != 1 branch (playback.c:1623):
uVar19 = FUN_1000_ceec(puVar15[6], puVar15[7], 0xff52, 0xffff); /* D * -174 */
param_1[6..7] = A + B + E + uVar19 + 1; /* the CH4 seek */
Slot maps (both are uint* over 16-bit words, so index n = byte offset 2n):
param_1 = &DAT_1e77_3164 |
param_2 = puVar15 = &DAT_1e77_2e04 |
||
|---|---|---|---|
[0..1] = 3164 |
nb3 seek |
[0..1] = 2e04 |
A |
[2..3] = 3168 |
FP seek | [2..3] = 2e08 |
B |
[4..5] = 316c |
mb seek |
[6..7] = 2e10 |
D — the CH1–3 and CH4 counter |
[6..7] = 3170 |
CH4 seek | [8..9] = 2e14 |
E — FP counter |
What is settled:
FUN_1000_ceecis a 32×32→32 multiply (playback.c:8141), not a divide. So the term is genuinelyD × −174, and0xffffff52= −174 = the wide-band payload size (175 − 1).- The result only has to land within one frame: earlier in the same function
param_1[0..1]and[2..3]are built by seeking, then scanning forward to the next0x20/0x40marker and counting the bytes skipped. A forward scan absorbs the remainder. - Elsewhere in the function
ceec(x, 0x106)andceec(x, 0xaf)convert frame counts to bytes, andceec(x, 0x1d)converts frames to groups (29 per frame) for the log written at the end. SoDandEare frame counts in those uses.
What does not add up: solving A + B + E + 1 − 174·D = the known CH4 offset, under the
natural guess (A = 620 header size, B = absolute file offset of the first wb marker,
E = wb byte length), gives
| Kasus_01 | Kasus_02 | |
|---|---|---|
implied 174·D |
10,984,512 | 10,943,896 |
implied D |
63,129.4 (66 bytes off an integer) | 62,896 exactly |
actual nb3 frame count |
62,752 | 62,516 |
| excess | ≈ 377 | 380 |
The 66-byte residual on Kasus_01 is within one 175-byte frame, so the forward scan would
still land correctly — but the implied D is not the narrow-band frame count in either
file, and E is being added as a byte quantity here while the 31b8 == 1 branch subtracts
it as a frame count. At least one of the operand identities above is wrong.
Hypotheses tested and rejected:
D= nb3 frames + nb3 control records — Kasus_01 gives 63,122 (tantalisingly near the 63,129 needed) but Kasus_02 gives 66,667 against a required 62,896. Not it. Record counts differ wildly between the two files (370 vs 4,151 innb3; see below).FUN_1000_ceecas a divide, withD= thenb3byte length — lands ~10.8 MB past the correct offset. Ruled out anyway now thatceecis confirmed to be a multiply.
Control-record census, for whoever picks this up:
Kasus_01 'nb3 ': frames= 62,752 records= 370 record_bytes= 2,215
Kasus_01 'wb ': frames=250,625 records=1,888 record_bytes=11,303
Kasus_02 'nb3 ': frames= 62,516 records=4,151 record_bytes=17,547
Kasus_02 'wb ': frames=250,611 records=1,945 record_bytes=11,620
None of this blocks decoding — DAT_1e77_2e04/3164 are PLAYBACK's own bookkeeping,
filled by the demuxer FUN_1000_26e4 from the raw download, and our .dlu files are
already demultiplexed. It matters only if a future file breaks the frame-count rule.
Beware when scoring
Kasus_01/WAV1.WAV and Kasus_02/ZS-SJU_CH1.WAV share identical opening samples
(16, 0, 0, 0, 0, 0, 0, 0) — that is decoder start-up silence, not evidence of anything.
The narrow-band channels are also near-silent for most of their length (rms ≈ 1.1,
max 16 in WAV1), so a naive "fraction of samples equal" score sits at 0.8+ purely from
matching zeros. Anchor on a high-variance window, or score over ≥600 samples requiring
≈1.0.
6. The tool
cvrtool/
cvrtool.py CLI: .dlu -> WAV files (also --verify against a reference)
region_start / ch4_extent locate where a decode must begin
cvrdec.py frame/marker/record de-framing + group unpacking;
frame_offsets() walks a region's framing without decoding
g723_d24.py literal 1:1 port of SSCVRDLL.DLL!G723_D24 (state keyed by DAT offset)
dlu.py DLU2 container + WAV header parsing
matchref.py lag-aligned verification of every region against every reference WAV
identify.py sweep every region x layout x offset against reference WAVs
(superseded by matchref.py: it compares at offset 0 and so misses
the lead-in described in section 5)
invert.py DFS: recover the code stream that produces a given WAV
sweep.py broad packing-hypothesis sweep (bit order, byte transforms, strides)
scan_offsets.py exhaustive bit-position scan with early abort
find_packing.py locate a known code sequence inside a region
search_interleave.py first-pass interleave test
ghidra_scripts/DumpDecomp.java headless decompiler dump script
decomp/sscvrdll.c 78 functions, full decompilation
decomp/playback.c 345 functions, full decompilation
gproj/ Ghidra project (CVR2) with both binaries imported + analysed
How to run it
cd /home/retorikal/Downloads/K01/cvrtool
/usr/bin/python3 cvrtool.py ../Examples/Kasus_01/DOWNLD01.dlu -o out/ # everything
/usr/bin/python3 cvrtool.py ../Examples/Kasus_01/DOWNLD01.dlu -o out/ -r wb -d 60
/usr/bin/python3 cvrtool.py ../Examples/Kasus_01/DOWNLD01.dlu -o out/ -r ch4
Writes <base>_CH1/_CH2/_CH3.wav (8 kHz), <base>_WB.wav (16 kHz), <base>_MB.wav
(8 kHz) and <base>_CH4.wav (16 kHz). -r selects nb3 / wb / mb / ch4 (default:
all four), -d N decodes only the first N seconds, --verify REF.WAV compares at offset 0
instead of writing (use matchref.py if a lead-in is involved — ch4 has none, so
--verify works directly on it).
Call /usr/bin/python3 explicitly. A bare python3 may resolve to
~/Workspace/mossduel/.venv/bin/python3, which has no numpy and dies with
ModuleNotFoundError.
Performance caveat
The port is pure Python and literal by design (≈40,000 samples/s). A full DOWNLD25.dlu
is ≈218 M samples ≈ 1.5 hours of CPU (nb3 ≈6 min per channel, wb ≈48 min, mb ≈24 min).
Fine for validation, too slow for production. The
format is now fully known, so the obvious follow-up is a C or Rust port of
g723_d24.py (the de-framing in cvrdec.py is already cheap) — or just run the existing
code under PyPy. Keep g723_d24.py as the reference oracle for whatever replaces it.
7. Remaining work
- Port the codec to C/Rust for usable throughput (see caveat above). This is now the main blocker to production use; the format itself is settled.
- Reproduce PLAYBACK's lead-in. Our output starts +96/+72 samples later than
PLAYBACK's. Harmless for analysis but it means output is not byte-identical to the
legacy tool from sample 0. Worth pinning down where those samples come from (the region
appears to start mid-frame, and PLAYBACK seems to have the preceding bytes available).
ch4is exempt — it starts on a real frame boundary and matches at lag 0. FUN_1000_2d0c's seek arithmetic is still unreconciled (§5). Cosmetic: the frame-count rule reproduces CH4 bit-exactly without it. Would matter only if a file turns up where CH4's length is not the narrow band's frame count, or for the untested0x20-marker path.GCDformat — magicFF FF FF FF, entirely separate reversing job.Kasus_B02's 27.7 MB.gcdyields similar 4×WAV output, so it compresses harder than DLU2.- Optional: decode the
eeEEPROM dump (device metadata, 8192 bytes) — not audio, but it may carry recorder serial / config useful for provenance.
Note on content, not format: the three narrow-band channels are near-silent in both example
recordings (WAV1 rms ≈ 1.1, max 16 over most of its length) — that is genuinely what is in
the data, not a decode fault. mb is quiet in DOWNLD01 but carries a strong signal in
DOWNLD25 (rms ≈ 258, peaks to full scale).
8. Environment / artifacts
- Ghidra project (this session's, both binaries analysed):
cvrtool/gproj/, projectCVR2. The older user projectCVR File Decompression Software/CVR.rep/contains onlyPLAYBACK.EXE. - Regenerating the decompilation (the old
/tmp/cvr/artifacts were lost to a reboot — don't put them in/tmpagain):cd cvrtool ~/Downloads/ghidra_12.0.4_PUBLIC/support/analyzeHeadless ./gproj CVR2 \ -import ./SSCVRDLL.DLL -processor "x86:LE:16:Real Mode" \ -scriptPath ./ghidra_scripts -postScript DumpDecomp.java $PWD/decomp/sscvrdll.c - Prior work by user:
DECOMPRESSDLGPROC.c,CRASHEROUTPUT.cpp,PLAYBACK_PATCH.EXE,backtrace.txt. - Test cases:
Examples/Kasus_01/(2017),Examples/Kasus_02/(2025 ZS-SJU), plus GCD-formatKasus_B01/,Kasus_B02/. - Tools: Ghidra 12.0.4 at
~/Downloads/ghidra_12.0.4_PUBLIC/with OpenJDK 21; Wine 11 / winedump; ffmpeg. The only Python dependency is numpy, which is present in the system interpreter (/usr/bin/python3, numpy 1.26.4) but not in the~/Workspace/mossduel/.venvthat a barepython3may resolve to. Always invoke/usr/bin/python3. - Reference WAV gotcha: several reference files declare 4 more
databytes in the RIFF header than the file actually contains (WAV1.WAVsays 29,117,124, file has 29,117,120; same forWAV4,ZS-SJU_*).np.memmapraises "mmap length is greater than file size" unless the length is clamped to the real file size —dlu.wav_samplesandmatchref.ref_arraydo this.