No description
Find a file
reinard.setiadji@formulatrix.com 708e2779f3 README fix
2026-08-31 15:51:26 +07:00
decomp firstcommit 2026-08-30 14:35:17 +07:00
ghidra_scripts firstcommit 2026-08-30 14:35:17 +07:00
gproj firstcommit 2026-08-30 14:35:17 +07:00
cvrdec.py firstcommit 2026-08-30 14:35:17 +07:00
cvrtool.py firstcommit 2026-08-30 14:35:17 +07:00
dlu.py firstcommit 2026-08-30 14:35:17 +07:00
find_packing.py firstcommit 2026-08-30 14:35:17 +07:00
g723_d24.py firstcommit 2026-08-30 14:35:17 +07:00
identify.py firstcommit 2026-08-30 14:35:17 +07:00
invert.py firstcommit 2026-08-30 14:35:17 +07:00
matchref.py firstcommit 2026-08-30 14:35:17 +07:00
README.md README fix 2026-08-31 15:51:26 +07:00
scan_offsets.py rewrite bitstream framing section 2026-08-31 12:05:33 +07:00
search_interleave.py firstcommit 2026-08-30 14:35:17 +07:00
sweep.py firstcommit 2026-08-30 14:35:17 +07:00

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

  1. DAT_11df_00a0 — the TRANS reset flag for the dq/sr delay line — is never written. Only 0x1cf6 (reset) zeroes it. So helper 0x0db6 always passes the previous value through and the delay-line reset on transition is dead code, unlike the reference implementation.
  2. 0x18ea (TRANS) clamps at ylint >= 9 to 31<<9 (0x3e00); the reference clamps at ylint > 9 to 31<<10.
  3. DAT_11df_0012 — the tandem-adjusted µ-law codeword — is computed and stored but never read. The returned sample is plainly ulaw2linear(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:

  • 0x20 and 0x40 both introduce frames, setting DAT_1d50 to 1 or 2 respectively.
  • The current band is held in DAT_1e77_1d4e (0/1/2 → 261/174/87-byte frames).
  • Bytes 0x00–0x07 are the control records of §4.2.
  • 0x80, 0x81, 0x82, 0x83 and 0xa0 raise 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

  1. 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.
  2. 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 in nb3 , decoded from a fresh G723_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_ceec is a 32×32→32 multiply (playback.c:8141), not a divide. So the term is genuinely D × −174, and 0xffffff52 = −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 next 0x20/0x40 marker and counting the bytes skipped. A forward scan absorbs the remainder.
  • Elsewhere in the function ceec(x, 0x106) and ceec(x, 0xaf) convert frame counts to bytes, and ceec(x, 0x1d) converts frames to groups (29 per frame) for the log written at the end. So D and E are 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 in nb3 ; see below).
  • FUN_1000_ceec as a divide, with D = the nb3 byte length — lands ~10.8 MB past the correct offset. Ruled out anyway now that ceec is 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

  1. 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.
  2. 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). ch4 is exempt — it starts on a real frame boundary and matches at lag 0.
  3. 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 untested 0x20-marker path.
  4. GCD format — magic FF FF FF FF, entirely separate reversing job. Kasus_B02's 27.7 MB .gcd yields similar 4×WAV output, so it compresses harder than DLU2.
  5. Optional: decode the ee EEPROM 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/, project CVR2. The older user project CVR File Decompression Software/CVR.rep/ contains only PLAYBACK.EXE.
  • Regenerating the decompilation (the old /tmp/cvr/ artifacts were lost to a reboot — don't put them in /tmp again):
    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-format Kasus_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/.venv that a bare python3 may resolve to. Always invoke /usr/bin/python3.
  • Reference WAV gotcha: several reference files declare 4 more data bytes in the RIFF header than the file actually contains (WAV1.WAV says 29,117,124, file has 29,117,120; same for WAV4, ZS-SJU_*). np.memmap raises "mmap length is greater than file size" unless the length is clamped to the real file size — dlu.wav_samples and matchref.ref_array do this.