# Power C — reverse engineering and improvements Goal: faster code and smaller programs from the Spinnaker **Power C** compiler on `reference/POWERc.d81`, **without touching the original**. Everything here works on copies (`reference/POWERc.d81` is only ever read; its SHA-256 is in `POWERc.d81.sha256`). Result (all verified in VICE, programs print identical output before/after): | program | original | improved | | |---|---|---|---| | `vim` editor (5 units) | 22,506 bytes | 19,793 bytes | −12.1 % | | `tests/sem.c` (semantics test) | 6,428 | 5,943 | −7.5 % | | `tests/bench.c` loop benchmark, jiffies (lower is better) | calls 28, array 27, branch 20, arith 30 | calls 26, array 17, branch 11, arith 18 | −7 % / −37 % / −45 % / −40 % | It also found and fixed a **bug in the original runtime**: the unsigned `>=` helper (`c0077.obj`) has a wrong opcode (`$80` instead of `$90`), so `unsigned a, b; r = a >= b;` yields 1 for 1 >= 5 (`tests/uge.c`). Comparisons inside `if` were unaffected; value contexts were. ## Quick start ``` python reference\powerc-re\tools\setup.py # once: extract lib objects, build optlib\ (add --disk for POWERc-opt.d81) powershell -NoProfile -File reference\powerc-re\tools\pcbuild.ps1 -Src hello.c -Name hello -Run '\n' python reference\powerc-re\tools\regress.py # optimizer on/off, compares the screens ``` `pcbuild.ps1` compiles with the original `cc` inside VICE, runs `pcopt.py` on every `.o`, links with the original linker using the improved runtime objects, and (with `-Run`) starts the program and saves the final screen. `-NoOpt` gives the original behaviour for comparison. Sources are plain ASCII; the scripts convert to PETSCII. Remember Power C is K&R C and cannot type `||` (see `skills/powerc/SKILL.md`). ## How the toolchain works (reverse engineered) * `cc.sh` runs two passes through temp files: `compiler` (source → intermediate) and `translator` (intermediate → `.o`). The result is **native 6502 code**, generated by naive template expansion: every value is a 16-bit pair in X (low) / Y (high); temporaries live in a software "temp stack" at `$033C` up; locals live in zero page from `$2B`; every operator on non-trivial values calls a tiny runtime helper `C$00nn` that works on that temp stack. * Calls use a software frame (`$14/$15` frame pointer, `$16/$17` argument pointer, `$1A/$1B` push stack). `C$105` (prolog) and `C$106` (epilogue) in `c105.obj` copy an 8-byte header plus all of the callee's zero-page locals each call — about 450 + 530 cycles, which dominates call-heavy code. * `C$RETURN` (inside `c105.obj`) is the 5-byte return-value slot; `C$0112` copies a value into it. * The linker pulls objects by name from the `stdlib.l` / `syslib.l` index files (`symbol NUL file NUL` pairs), so replacing e.g. `cmulti.obj` on a disk copy replaces that routine in every program. ### Object file format (`.o` / `.obj`) — `tools/objfmt.py` round-trips every object on the disk ``` u16 code_len ; code[] native code, data (globals, strings) embedded in it u16 n ; u16 off[n] opcode offsets of instructions whose 16-bit operand is a code-relative address (linker adds the load base): local jmp/jsr, pointers to data u16 n ; { name\0 u8 kind u16 off } definitions (functions, globals, string constants) u16 n ; { name\0 u16 kind u16 site } references to other objects; site = opcode offset kind 0 = abs16, 1 = high byte (#>), 2 = low byte (#<), 4 = abs16 + 1 u16 n ; { name\0 u16 size } reserved (uninitialised) data blocks ``` Relocation placeholders in the code are the opcode byte repeated (`20 20 20` = `jsr extern`). The linker overwrites them. Names may contain odd bytes (string constants are `<2 garbage>S$`). ## The tools | file | purpose | |---|---| | `tools/pcopt.py` | peephole optimizer for `.o` files (see below) | | `tools/objfmt.py` | object format reader/writer; `python objfmt.py x.obj` checks the round trip | | `tools/pcasm.py` | tiny assembler that writes Power C objects directly (used for `src/cmulti.s`) | | `tools/cpu6502.py`, `helpertest.py`, `t_*.py`, `classify.py` | 6502 emulator + harnesses that prove a rewritten helper behaves exactly like the original (random and edge-case inputs, cycle counts) | | `tools/dis.py` | disassemble an object with its relocations/references | | `tools/pc-compile.ps1`, `pc-run.ps1`, `pcbuild.ps1` | drive the original `cc`/`link` in VICE on throw-away disk copies | | `src/cmulti.s`, `src/div_patch.py` | the replacement runtime routines | ### What `pcopt.py` does It decodes each compiled function (found via the `sta $FB` prolog and the `jmp C$106` epilogue), rewrites it with a liveness analysis (A/X/Y/C/N/Z/V, plus temp-stack memory) and re-emits the object with every offset, branch, relocation and definition fixed up. Anything it cannot decode with certainty is left untouched; with `--no-rules` it reproduces the input byte for byte. Rules: inline the `<`, `>`, `<=`, `>=`, `==`, `!=` helpers (signed and unsigned) when they feed an `if`/loop test, including `&&` chains; return values stored straight into `C$RETURN`; reload elimination (`stx/sty` then `ldx/ldy` of the same slot); `ldx #K;ldy #K;stx;sty` → `lda #K;sta;sta`; 16-bit `+1` / `+K`; jump-to-next and branch-over-jump removal; `==`/`!=` against a constant as two branches instead of a branch plus `jmp`. ### Faster runtime routines (`optlib/`, built by `setup.py`) * `cmulti.obj` — multiply that needs only 8 shift/add steps when an operand is < 256 (the other 8 steps of the original merely shifted the 32-bit register by one byte); ~40 % faster for small factors, +69 bytes of runtime when `*` is used. * `c0047.obj` — divide/remainder that normalises the divisor only as far as the dividend needs; same quotient and remainder, e.g. 100/7 is ~2× faster. (30000/3 is ~10 % slower.) * `c0077.obj` — the opcode fix described above. ## Safety Equivalence of the replaced runtime routines is checked in the emulator against the originals over thousands of operand pairs (`tools/t_multi.py`, `t_div.py`). The optimizer's rewrites rely on the compiler's temp-stack discipline (operand slots are dead after the operation that consumed them) and on the calling convention (A/X/Y are arguments, flags never are); programs are validated end to end (`regress.py`, the `vim` editing scenario). A bug found that way (an `&&` chain read a result slot the inlined compare no longer wrote) is covered by `tests/sem.c`. ## Not done / ideas * Prolog/epilogue (`c105.obj`) — dominates call cost but its frame layout is shared with `setjmp`/`longjmp`; micro-optimising the copy loops would buy roughly 10–15 %. * A real fix needs a better code generator (patching `translator`): keep values in registers, stop saving zero-page locals on every call. * Inline expansion of `C$1102` (push) and the pointer load/store helpers (`C$1021…`): faster but larger. * Arrays/long expressions: only the idioms above are matched, so gains shrink for code that is mostly helper calls.