< Back to WIP

Posted 2026-08-12

The Four Bytes That Were Never There: A Colour Profile, a Missing Checksum, and the Wrong Suspect

Markdown source

Range: 7f708b1 to 3e69b9c (v0.0.149 → v0.0.150)

pumpit8 (Pump It Up: The Rebirth) cleared ROM/CHD mounting, executable loading, JAMMA, and security-chip initialization, then terminated with a 0xC0000005 access violation about 97 seconds in while reading a background animation.

Every observation pointed at libpng and zlib. Yet the file finally changed contains no PNG code at all. libpng, zlib, and the asset were all behaving "correctly"; the only thing acting differently from the original was the address space.

This is the record of reaching the real cause after two wrong suspects.

Major Changes

1. Symptom and first clue — inflate succeeded and failed

The termination sat in libpng 1.0.6's iCCP chunk path. iCCP embeds an ICC colour profile in a PNG, and the profile body is zlib-compressed. The z_stream state right after the decompression helper returned was:

Field Value Reading
return Z_OK (0) not an error
avail_in 0 input fully consumed
total_in 0xA20 (2,592) consumed exactly what it was given
total_out 0xC48 (3,144) output complete
avail_out 0x13B8 buffer had room to spare

The 3,144-byte output matched Windows' sRGB Color Space Profile.icm. The profile body was reconstructed perfectly, yet the result was Z_OK rather than Z_STREAM_END.

libpng 1.0.6's png_decompress_chunk loops while (avail_in), exits when input runs out, and returns null if ret != Z_STREAM_END. It builds the whole payload and then discards it because the stream never closed.

A zlib datastream ends with a 4-byte adler32 after the deflate blocks. Consuming all input without reaching termination means that tail is missing. The question was where it went missing.

2. The diagnostic — a 32-byte window could not show it

Inspecting the original asset statically was blocked. BGA/083.DAT is a RES\0 version-3 container whose 390,039-byte payload after the 16-byte header is entirely high-entropy, with no PNG signature and no iCCP string anywhere in the file. The PNG exists only at runtime.

The existing execution probe captured only 32 bytes per register — far too little for a 2,615-byte chunk — so the diagnostic came first (147ec90).

REPIU_EXECUTION_PROBE_DUMP_* writes a chosen guest memory range to a file at the probe's first hit.

  • The base is a register name or an absolute address, with optional indirect dereference to follow a pointer inside a structure.
  • The buffer is reserved before execution starts, so the exception handler never re-enters the heap lock.
  • The file is written after the guest thread stops; no file I/O happens inside the VEH.
  • The bytes never travel through the telemetry snapshot, so polling does not copy kilobytes each iteration.

The procedure is recorded in docs/guides/execution-probe-memory-dump.md.

3. Exactly four bytes

The extracted chunk was validated with an independent host zlib decoder.

whole chunk    : 2,615 (0xA37)
keyword        : 'Photoshop ICC profile'   prefix 23 (0x17)
compressed     : 2,592 (0xA20)   zlib head 78 9c   valid
inflate output : 3,144 (0xC48)   'acsp'  declared size 0xC48   valid

adler32(output): 0xF784F3FB
last four bytes: 4F 9F 7E 13      <- not a checksum

How many bytes are missing can be anywhere from one to four depending on bit alignment, so the exact end of the deflate stream was measured by trimming the tail one byte at a time.

raw len 2590 -> inflated 3144
raw len 2589 -> inflated 0
raw len 2588 -> inflated 0
raw len 2587 -> inflated 0     ... zero for everything below

Removing even one byte from 2,590 (= compressed 2,592 − the 2-byte zlib header) yields nothing. The deflate blocks therefore end exactly on the last byte of the compressed field, leaving no room at all for the adler32. The shortfall is exactly four bytes.

4. The reversal — the CRC matched

The natural next hypothesis was that rePIU had dropped four bytes somewhere. But the chunk's length field and its actual data length agree. Had only the data been truncated, libpng would have read the last four of those 0xA37 bytes as the adler32 and failed with Z_DATA_ERROR. The observation was Z_OK, so length and data are short together.

Dereferencing the pointer at png context +0x0C and dumping 1 MiB produced the PNG stream body.

Guest address Chunk Length
0x04B20320 IHDR 13
0x04B20339 iCCP 2,615
0x04B20D7C gAMA 4
0x04B20D8C cHRM 32
0x04B20DB8 IDAT 23,935

IHDR is 256×256, bit depth 8, colour type 6, and every chunk boundary lines up. The PNG stream's own length field reads 0xA37, ruling out a length-propagation error.

The CRC settled it.

chunk type    : 'iCCP'
computed CRC32: 0x8FC1BA08
stored CRC32  : 0x8FC1BA08
                match

A four-byte loss at any stage would break the CRC. This chunk reached memory exactly as authored. The file reads were complete too: 4,096 + 385,536 + 407 = 390,039 bytes, the final short read being ordinary EOF. rePIU's file I/O, RES decryption, and PNG delivery are all exonerated; the asset was authored this way. Of seven PNG signatures in the same 1 MiB window, only this one carries an iCCP chunk.

5. Why the original board survived

If the asset was always broken, libpng returned null on the original hardware too — yet the game ran fine in arcades. So what differs is what happens after the null. Static disassembly of the call site with repiu_aot_probe:

0x010E5CFC  call 0x010E49F8        ; png_decompress_chunk -> NULL
0x010E5D01  mov  ebp, eax
0x010E5D03  lea  ebx, [eax+edi*1]  ; 0 + 0x17 = 0x17
0x010E5D06  mov  edi, ebx
0x010E5D08  sub  ecx, ecx
0x010E5D0A  dec  ecx               ; ecx = 0xFFFFFFFF
0x010E5D0B  xor  eax, eax          ; al = 0
0x010E5D0D  repne scasb            ; strlen(0x17)  <- faults here
0x010E5D0F  not  ecx
0x010E5D11  dec  ecx               ; ecx = length
0x010E5D20  call 0x010E9088        ; png_set_iCCP

No conditional branch separates the call from the fault. libpng 1.0.6 adds the prefix 0x17 and dereferences without checking the return. That is a latent bug in the original binary, not a rePIU branch mistranslation.

With EAX=0 plus EDI=0x17, the instruction asks to read from linear address 0x17 until a NUL appears. DOS/4GW's flat selector has base 0, so 0x17 is DOS low memory (the interrupt vector table) and readable. In rePIU a guest address is a host address, and Win32 permanently reserves the lowest 64 KiB, so nothing can be mapped at 0x17.

6. The real defect and the fix

rePIU already had a low-memory servicing facility: HandleGuestLowMemoryReadFault catches access violations below 64 KiB in the VEH and returns values from a zero-initialized low-memory image. But it handled only three instructions.

if (instruction.mnemonic != ZYDIS_MNEMONIC_MOV &&
    instruction.mnemonic != ZYDIS_MNEMONIC_MOVZX &&
    instruction.mnemonic != ZYDIS_MNEMONIC_MOVSX)
{
    context->debug_emulate_stage = 4; // Wrong mnemonic
    return false;                     // <- scasb is rejected here
}

The full chain:

flowchart TD
    A["Asset: iCCP zlib stream lacks adler32<br/>authenticity proven by CRC32 match"] --> B["zlib: consumes all input, completes output<br/>never reaches Z_STREAM_END, returns Z_OK"]
    B --> C["libpng png_decompress_chunk<br/>returns null"]
    C --> D["Original code: no null check<br/>lea ebx,[eax+edi] then repne scasb at 0x17"]
    D --> E{"Is linear 0x17 readable?"}
    E -->|"DOS/4GW<br/>low memory mapped"| F["Counts a junk length and proceeds"]
    E -->|"rePIU<br/>handler rejects scasb"| G["0xC0000005, fatal"]

The fix widens the low-memory facility to the instruction set the guest actually uses (3e69b9c). It bypasses no address and manipulates no libpng return value; it completes a facility that already existed.

The difficulty is that REP forms differ fundamentally from MOV. MOV is done once you read and step EIP; repne scasb carries repetition state in ECX/EDI. The solution rests on a property the architecture already guarantees: REP forms are defined to be interruptible between iterations, and therefore restartable.

flowchart TD
    F["Low-memory access violation"] --> D["Decode instruction at EIP"]
    D --> C{"category == STRINGOP?"}
    C -->|no| M["Existing MOV/MOVZX/MOVSX path"]
    C -->|yes| L["Perform one iteration<br/>read, flags, pointer step, ECX decrement"]
    L --> T{"REP termination condition?"}
    T -->|yes| S["Advance EIP — instruction complete"]
    T -->|no| N{"Next address still low memory?"}
    N -->|yes| L
    N -->|no| K["Leave EIP — CPU resumes the instruction"]

The handler need not reproduce the loop. It advances EIP only when the repetition finished and otherwise leaves it alone: if the next address is still low memory it faults again and is serviced again; if it has left, the CPU resumes the same instruction from the current ECX/ESI/EDI. Both are correct, so the handler never tracks where low memory ends.

Three correctness details mattered.

  • Width comes from the mnemonic. A 66h prefix selects a different mnemonic outright (SCASW versus SCASD), making the mapping exact and independent of how the decoder reports operand width for byte forms.
  • Category gates first. SSE's cmpsd shares its mnemonic with the string CMPSD, so ZYDIS_CATEGORY_STRINGOP separates them.
  • Flags are computed per width. repne termination depends on ZF, so the existing 8-bit-only helper was generalized to take a width.

The declining boundaries are stated rather than guessed: MOVS already has a dedicated handler, STOS writes and needs its own justification, and a non-32-bit address size, a straddling access, an unreadable CMPS counterpart, and a REP with ECX already zero all decline so the existing diagnostics report the real cause.

7. Execution log — current progress point and blocker

The pumpit8 termination before the fix:

[loader] Win32 minimal execution exception code: 0xC0000005
[loader] Win32 minimal execution exception EAX: 0x00000000
[loader] Win32 minimal execution exception EBX: 0x00000017

EAX is the null return and EBX is that plus the prefix 0x17. The same site after the fix:

[loader] Win32 low-memory string service/iteration count: 2522/2522
[loader] Win32 last low-memory string EIP/address/mnemonic/iterations: 0x040E5D0D/0x00000017/711/1
[loader] Win32 last DOS open guest path: AUDIO\004.AUD
[loader] Win32 minimal execution message: minimal execution stopped by SDL exit request

Mnemonic 711 is ZYDIS_MNEMONIC_SCASB.

Item Before After
Termination 0xC0000005 at ~97 s zero exceptions
String services / iterations — 2,522 / 2,522
Reached BGA/083.DAT AUDIO/004.AUD
Display — rendering at ~6.9 FPS

Services and iterations are exactly equal. The low-memory image is zero-initialized, so the repne scasb terminates on its first byte with ZF=1 and yields a length of zero — precisely as the design predicted, and the same character as the original scanning the IVT for an arbitrary length.

The run ended on an SDL exit request issued to stop the observation, not on an access violation. The MOV path counter is zero both before and after, so that path is unchanged.

Blocker status. Only this site is closed. It does not mean the game can be completed; observation confirmed 148 seconds of exception-free rendering and no further.

Sample test results

All 819 samples were built and the suite re-measured in the same Release configuration as the baseline.

Item 0.0.133 (Debug) 0.0.135 (Release, baseline) 0.0.149 (Release, this run)
Total 819 819 819
BuildPassed / BuildSkipped 793 / 26 793 / 26 793 / 26
RunEligible 793 793 793
RunPassed 535 528 528
RunPassRate — — 66.6%
OverallPassRate — — 64.5%

The baseline comparison is zero regressions and zero new passes — exactly identical.

{
    "BaselineVersion":  "0.0.135",
    "NewPass":  [ ],
    "Regressions":  [ ]
}

This matches the expectation that the change is confined to a VEH path firing only on low-memory access violations. The suite's DOS samples never reach a path that reads low memory through repne scasb, so the servicing counters stay at zero and the pass count does not move. Note that this confirms the absence of impact rather than validating the path; the actual validation of string servicing is the pumpit8 execution observation.

The gap between 0.0.133 (535) and 0.0.135 (528) comes from the configuration moving from Debug to Release; reading it as a regression is a misreading. This 0.0.149 run is the same Release configuration and lands on 528 exactly.

xychart-beta
    title "OpenWatcom Sample Cumulative Results"
    x-axis ["0.0.1", "0.0.4", "0.0.5", "0.0.9", "0.0.15", "0.0.34", "0.0.59", "0.0.133", "0.0.135", "0.0.149"]
    y-axis "Samples" 0 --> 850
    line "Total" [819, 819, 819, 819, 819, 819, 819, 819, 819, 819]
    line "Build Passed" [788, 793, 793, 793, 793, 793, 793, 793, 793, 793]
    line "Run Passed" [419, 470, 473, 473, 522, 523, 529, 535, 528, 528]

Build verification

  • cmake --build build/win32_x86_debug --config Debug --target repiu: passed
  • cmake --build build/win32_x86_debug --config Debug --target repiu_aot_probe: passed
  • scripts/build_openwatcom_samples.ps1 -Configuration Release: passed (819/819 built)
  • scripts/test_openwatcom_samples.ps1 -Configuration Release -CompareBaseline: passed (zero regressions)

Technology Stack Used

PNG chunk structure and CRC32

A PNG file is an 8-byte signature followed by chunks. Each chunk is length(4) + type(4) + data(length) + CRC(4), and the CRC32 covers the type and the data — not the length.

That is why the CRC was decisive here. Truncating the data by four bytes changes the CRC input, so the stored value would disagree. A matching CRC proves the whole chunk is as authored. When a data-movement path is under suspicion, checking the integrity field already attached to that data is the fast route.

The specification requires an iCCP compressed profile to be a zlib datastream (PNG 1.1 chunk specification).

zlib datastream termination and adler32

A zlib datastream is a 2-byte CMF/FLG header, deflate blocks, and a 4-byte adler32 over the uncompressed result (RFC 1950). The deflate block format itself is RFC 1951.

inflate() does not return Z_STREAM_END after unpacking the final block until it has read and verified those four bytes; when input runs out it returns Z_OK. The combination seen here — Z_OK, avail_in == 0, output complete — is exactly that state.

One caution: total_in alone cannot tell you the size of the shortfall. inflate may pull a few bytes ahead into its bit buffer, so the observed total_in can sit past the logical consumption point. Trimming the tail one byte at a time to find the minimum length is what settled it.

x86 string instructions and REP restartability

scasb compares one byte of AL against ES:[EDI], updates only the flags, and moves EDI by ±1 according to DF. It is read-only, so its side effects are EDI and the flags; the segment is always ES and cannot be overridden by a prefix.

repne runs each iteration as: terminate if ECX == 0, decrement ECX, execute scasb, terminate if ZF == 1. Because ZF is tested after each scasb, the flag's value on entry is irrelevant — the preceding xor eax, eax sets ZF=1, yet the loop still runs.

Setting ECX = 0xFFFFFFFF and AL = 0, then following repne scasb with not ecx; dec ecx, is Watcom's canonical strlen idiom. With the NUL at index n the loop runs n+1 times leaving ECX = 0xFFFFFFFF − (n+1); NOT gives n+1 and DEC gives n.

The property the implementation rests on is restartability. The Intel SDM specifies that REP forms may be interrupted between iterations, with state preserved in ECX and the index registers and the instruction pointer still addressing the instruction so it resumes (Intel® 64 and IA-32 Architectures Software Developer's Manual, Vol. 2, REP/SCAS). That makes servicing a single iteration and leaving EIP in place architecturally sound.

DOS/4GW low memory versus the Win32 null page

DOS/4GW provides a protected-mode flat selector with base 0 and a 4 GB limit, so a guest can read low memory from linear 0 — the interrupt vector table, the BIOS data area — directly. DOS-era code referencing low memory is the norm, not the exception.

rePIU uses guest addresses as host addresses (arena base 0x04000000). Win32 permanently reserves the lowest 64 KiB, so nothing can be mapped at 0x17. The project therefore services low-memory reads by catching the access violation in the VEH and returning values from a zero-initialized 64 KiB image. This work widens that servicing to string instructions.

This gap is not new. In v0.0.58 a path reading address 0 while parsing an empty texture descriptor hit the same wall, and the judgement made then — "an HLE gap, not a data bug" — applies again here.

Zydis decoding pitfalls

The fault handler decodes instructions with Zydis. Two traps matter.

  • SSE's cmpsd (compare scalar double) and the string CMPSD share ZYDIS_MNEMONIC_CMPSD. They must be separated with instruction.meta.category == ZYDIS_CATEGORY_STRINGOP.
  • Taking the width from operand_width makes the code depend on how byte forms report it. Since a 66h prefix changes the mnemonic itself, deriving width from the mnemonic is exact.

What can and cannot be done inside an exception handler

A VEH handler interrupts at an arbitrary point, which constrains it sharply. Two rules held throughout this diagnostic. Do not allocate — if the exception happened while the heap lock was held, re-entering deadlocks, so the dump buffer is reserved before execution starts. Do not perform file I/O — writing is deferred until the guest thread has stopped.

The read-range checks are conservative for the same reason: copy only when the full width lies inside the valid range, and decline a straddling access.