Input and serial fixes.

This commit is contained in:
Scott Duensing 2026-08-25 16:43:41 -05:00
parent 89631006f2
commit 91629dc5eb
12 changed files with 408 additions and 49 deletions

View file

@ -213,3 +213,235 @@ and #$ff`. ABI verified from a compiled probe: pointer lo16 in A, bank in X,
return in A, $e0-$e3 imaginary-register scratch. Any OTHER volatile I/O read return in A, $e0-$e3 imaginary-register scratch. Any OTHER volatile I/O read
through a variable pointer (input/video HALs, future code) is still exposed through a variable pointer (input/video HALs, future code) is still exposed
until the codegen fix lands. until the codegen fix lands.
## 8. Peeled-first-iteration address negation is one's complement, not two's (2026-08-20) -- FIXED IN TOOLCHAIN same day (see end of item)
A loop that indexes a local array at a base computed by SUBTRACTING a
uint8_t parameter (`k = 8u - nb;` then `t[(uint8_t)(k + i)]` inside a
counted loop) is compiled with the FIRST iteration peeled, and that
peeled iteration's address is computed as
base + sign_extend(~nb) ; one byte LOW: ~nb = -nb - 1
where two's complement (`-nb`) was needed. Every later iteration
increments correctly, so exactly the first element is read one byte
low and the remaining seven are right -- a silent wrong-VALUES class
with no crash, in the same derived-induction-variable family as the
modulo-advance item.
The emitted idiom (RetroNet src/crypto/rnSipHash.c sipRot, w65816
-O2 -ffreestanding -ffunction-sections, disassembly of the peeled
first read's address):
lda 0xb6,s ; nb
eor #0x1aff ; low byte: nb ^ 0xff = ~nb (high byte junk)
and #0xff
eor #0x80 ; sign-extend byte
clc
adc #0xff80 ; (x ^ 0x80) - 0x80
...
tsc
clc
adc #0xa3 ; t[] frame base (t + 8 region)
clc
adc 0x1f,s ; += sext(~nb) --> t + 8 - nb - 1 = t[k-1]
Blast radius measured before the workaround: EVERY SipHash-2-4 tag the
IIgs client computed was wrong (the rotate primitive feeds every round),
so every sealed uplink failed server-side authentication. Nothing
client-side ever caught it because the protocol's selective encryption
leaves downlink frames plaintext -- silent until a peer verifies.
Root-caused via on-target known-answer tests + a per-primitive state
probe (SIPBLOCK) diffed against the host build of the same source; the
"first iteration reads index k-1" model reproduces both on-target wrong
tags bit-for-bit end to end.
WORKAROUND LANDED IN RETRONET: sipRot now takes k (the precomputed read
base) instead of nb, removing the 8-nb negation from the function; the
emitted first-iteration address becomes a plain base + k and all KATs
pass on target. Any other loop whose peeled first iteration derives
its address from a subtracted narrow parameter is still exposed.
RESOLUTION (2026-08-20): root cause was NOT the address lowering -- the
IR and ISel were correct (a true NEGA8 pseudo). The AsmPrinter expanded
NEGA8 as an UNWRAPPED `EOR #$FF ; INA` on a false "already 8-bit M"
assumption: in the real 16-bit-M context the CPU decoded the 2-byte
EOR #imm8 as 3-byte EOR #imm16 and swallowed the INA opcode ($1A) as the
immediate's high byte -- the disassembly tell is `eor #$1AFF`. FIXED in
W65816AsmPrinter.cpp: NEGA8 now emits emitSepM/emitRepM around the pair,
exactly like LDA8absX/STA8absX (safe: SepRepCleanup's isMNeutral never
extends an M=8 window across the pseudo). Audited: NEGA8 was the only
unwrapped Imm8 expansion. Blast radius before the fix: 6 sites in the
RetroNet IIgs client -- the SipHash rotate (every sealed uplink failed to
authenticate) and 5 rnWidget functions (index corruption; wild-jump BRK
during the download offer frame; likely the phantom-FORM garbage too).
Original ask (kept for the repro): compile the pre-workaround sipRot
shape below at -O2 for w65816 and inspect the first read's address chain.
(Verbatim pre-workaround function; gSipV is a 40-byte global.)
```c
static void sipRot(uint8_t a, uint8_t nb, uint8_t mode) {
uint8_t t[16];
uint8_t i;
uint8_t k;
uint8_t x;
for (i = 0u; i < 8u; i++) {
x = gSipV[(uint8_t)(a + i)];
t[i] = x;
t[(uint8_t)(i + 8u)] = x;
}
k = (uint8_t)(8u - nb);
for (i = 0u; i < 8u; i++) {
x = t[(uint8_t)(k + i)];
if (mode == 3u) {
x = (uint8_t)((x >> 3) | (uint8_t)(t[(uint8_t)(k + i + 1u)] << 5));
} else if (mode == 1u) {
x = (uint8_t)((x << 1) | (uint8_t)(t[(uint8_t)(k + i - 1u)] >> 7));
}
gSipV[(uint8_t)(a + i)] = x;
}
}
```
## 9. i8 store to a 1-byte stack slot is a 16-bit STA d,s that overflows the return address (2026-08-20) -- FIXED IN TOOLCHAIN same day
M=16 is the default mode, so `sta d,s` writes TWO bytes. When the target
frame object is only 1 byte (an i8 spill / i8 arg-save) at the TOP slot of a
frame, the second byte lands on the caller's saved RETURN ADDRESS low byte and
the callee's RTL jumps mid-instruction into a different function. Live proof:
RetroNet's rnClientOnFileRecord saving its uint8_t `op` clobbered its own
return 0x80->0x01, so its RTL landed inside a different rasterizer switch case,
skipped the caller's arg cleanup, and left the DISPLAY record-walk's stack
pointer 4 low -> $F6 (big-frame pointer) wrong -> the walk byte-crawled forever
on the offer frame (the IIgs download "offer-silence": FILE_OFFER never
processed, client never drained the serial).
Minimal repro: `void fwd(uint8_t op, const uint8_t *r){ sink(op, r); }` at -O2
for w65816 emits `sta 0x2, s` (16-bit) to save op adjacent to the return.
FIX (W65816RegisterInfo.cpp eliminateFrameIndex, STAfi case): when
`MFI.getObjectSize(FI) == 1`, wrap the STA_StackRel in SEP #$20 / REP #$20 so a
1-byte slot gets a genuine 8-bit store. Only the STORE is a hazard; a 16-bit
LOAD of the extra byte is a harmless read (the high byte of an i8 value is
ignored), so LDAfi is left 16-bit. Verified: fwd now emits
`sep #$20 ; sta 0x1,s ; rep #$20`. Same defect family as ASKS #8 (NEGA8) and
the $F8 far-frame bank byte -- "an 8-bit operation emitted without the mode
wrap in default-16-bit-M code."
## 10. Pointer-deref to a GLOBAL hardcodes bank 0 (STZ $E2) under the GS/OS Loader (2026-08-20) -- FIXED IN TOOLCHAIN 2026-08-21 (provenance-based; see end)
The LDAptr / STAptr / STBptr custom inserter (W65816ISelLowering.cpp) lowers a
[dp],Y indirect-long deref with the BANK byte forced to 0 via `STZ $E2` (unless
the LoaderBankDeref path, which uses $BE, is taken). Bank-0 is correct for a
pointer to a STACK local (the 65816 stack is always bank 0) but WRONG for a
pointer to a GLOBAL: under the GS/OS Loader the program's BSS/globals are placed
in the program bank ($BE, e.g. $0A), not bank 0. So `&someGlobal` dereferenced
through this path reads bank-0 garbage, while the SAME global accessed directly
via `sta/lda abs` (DBR-relative, DBR=$BE) is correct -- a silent, layout-
dependent miscompile. The inserter's own comment already flags this tension
(gmtime's __gmtimeBuf hit it).
Live proof: RetroNet's inlined rnXferRequest materialised `&gX.id` (a static
global) and read it through LDAptr; the FILE_REQUEST then carried xferId=0xAD
(bank-0 garbage) instead of 1, and the server rejected the download. 0xAD is
not any valid gX field -- the tell of a wrong-bank read. Disasm:
`lda #<gX.id off> ; sta $e0 ; stz $e2 ; lda [$e0],y`.
WORKAROUND in app: `noinline` on the function so the global is read in the
caller through DBR-relative abs16 and passed by the ABI (same shape as the
existing rnXferState workaround).
Ask: the deref bank cannot be a blanket 0 or blanket $BE when stack pointers
(bank 0) and global pointers ($BE) are both derefed through one pseudo. The
principled fix is a 24-bit pointer representation (carry the bank in the pointer
value) or provenance tracking so a global-derived pointer derefs with $BE and a
stack-derived pointer with 0.
Fixes EVALUATED and rejected from the app side (2026-08-20):
1. -w65816-loader-bank-deref (existing flag): whole-program, flips EVERY
LDAptr to $BE. Fixes global derefs but breaks stack-pointer derefs (the
reason LDAptrBank0 exists for va_arg). Also the runtime *.o are shared
across Loader AND non-Loader (GNO/smoke) builds, so it can't be enabled
for just the S16/Loader target without a per-mode runtime split.
2. Fold (load <materialized global addr>) -> abs16+DBR at selection -- the
clean targeted fix, but NOT minimally reproducible: every small repro
(inlined forwarder, barrier-forced re-read, struct field) correctly
selects abs16. The mis-selection only appears inside a very large inlined
function (RetroNet rnFileRecord, ~0x916 bytes, three transfer routines
inlined), so it is a scheduling/CSE artifact of address materialization
under register pressure, not a stable pattern -- hard to fix safely
without provenance.
RESOLUTION (2026-08-21, W65816ISelLowering.cpp LDAptr/STAptr/STBptr custom
inserter): instead of a blanket bank policy, use PROVENANCE. When the pointer
being derefed traces (through COPYs) to an LDAi16imm of a global / external
symbol, the deref is exactly a DBR-relative absolute access to that global --
the same thing a direct `global` reference compiles to -- so emit LDAabs /
STAabs / STA8abs with the global operand and skip the [dp],Y deref entirely.
That lands in DBR's bank (correct for a global) with no bank guess, and only
GLOBAL-address derefs are touched -- stack pointers (bank 0) and heap/other
pointers keep the existing deref path, so no stack-out-param regression. NB the
bank canNOT be sourced from $BE: $BE is the code segment's PBR and in a
multi-segment program the data (BSS) segment is a different bank; only DBR (via
abs) is right. Single-segment builds are unaffected (DBR==0==old STZ bank).
Verified: RetroNet's rnXferRequest/rnXferState no longer need `noinline` (both
removed) and the inlined rnFileRecord emits zero bank-0 pointer-derefs; the IIgs
download DISK-VERIFIES from the pure-clean build. Also fixes the runtime gmtime
__gmtimeBuf case this item's parent note describes. What is still NOT covered:
a global address that flows through pointer ARITHMETIC the trace can't follow
(e.g. `p = &g; p += i; *p`) -- there the provenance is lost and the deref falls
back to bank 0; those remain candidates for the 24-bit-pointer rework.
## 11. ADDframe (stack-slot LEA) scheduled between a CMP and its branch clobbers the flags (2026-08-24) -- FIXED IN TOOLCHAIN same day
`ADDframe` - the LEA-equivalent that puts the address of a stack slot in A -
expands at PEI (W65816RegisterInfo.cpp eliminateFrameIndex) to `TSC; CLC;
ADC #disp`. That sequence rewrites N/Z (TSC, ADC), C (CLC, ADC) and V (ADC).
The pseudo deliberately carries no `Defs = [P]` (the same reasoning as the
LDA pseudos, see the NOTE above LDAi16imm in W65816InstrInfo.td) and is
`isReMaterializable`, so a post-RA pass may legally place it between a CMP
and the Bxx that consumes the CMP's flags. Machine Copy Propagation did
exactly that in RetroNet's src/platform/rnClient.c (main, the widget action
dispatch):
lda 0xf,s ; act.kind
cmp #0x3 ; == RN_WA_SUBMIT ?
tsc ; \
clc ; > ADDframe: &act, hoisted here for the SUBMIT arm
adc #0xc1 ; /
bne .LBB3_61 ; tests (SP+0xC1) != 0 - ALWAYS taken
so `act.kind == RN_WA_SUBMIT` could never be true: the LOGIN button
activated, rnWidgetActivate wrote kind=3, and rnClientOnSubmit was never
reached. On the IIgs the login form was never submitted; the harness's
Tab/Return retry loop then produced exactly nine no-widget Return uplinks,
which is what pinned it. A scan of every TU in the IIgs client found this
ONE instance (`cmp` immediately followed by `tsc` before the branch), which
is why only the submit was affected.
The existing mitigation - the PHP/PLP wrap in W65816StackSlotCleanup.cpp
Pass -2.5 - did not catch it for two reasons: (1) ADDframe was not in the
pass's corrupting set (isLdaLike), so the backward walk from the branch hit
it, took isFlagDefining's default ("anything else defines flags") and called
it the TEST, then declined to wrap; (2) the pass only collects BEQ/BNE/BMI/
BPL, on the (true) grounds that LDA-like ops leave C alone - but ADDframe
also clobbers C and V, so a `cmp; ADDframe; bcc` would have been miscompiled
with no protection at all.
RESOLUTION (2026-08-24, W65816StackSlotCleanup.cpp Pass -2.5):
* ADDframe is now in isLdaLike (N/Z-corrupting), so the walk continues
past it to the real CMP and wraps it: `cmp #3; php; tsc; clc; adc #disp+1;
plp; bne`. It was already in isStackRel and the bump list, so its
ImmOffset gets the +1 that compensates PHP's S decrement; the FP-relative
exemption in the bump is skipped for ADDframe because it has no FP-
relative expansion (always TSC+ADC, i.e. always SP-relative).
* BCC/BCS/BVC/BVS are collected as well, with a separate
isCarryCorrupting predicate (ADDframe only): under a C/V branch an
N/Z-only corrupter is walked past without a wrap, so the size of every
existing carry-branch sequence is unchanged.
* Pinned by llvm/test/CodeGen/W65816/addframe-flag-wrap.mir (nz, carry,
and the lda-under-carry no-wrap case).
Verified: the rnClient.c TU rescans clean (zero cmp/tsc/branch sequences in
all 20 IIgs client TUs) and the IIgs login submits live - see the RetroNet
e2e matrix (`joey-iigs session`).

View file

@ -104,6 +104,10 @@ typedef enum {
#define JL_CHAR_TAB 0x09 #define JL_CHAR_TAB 0x09
#define JL_CHAR_RETURN 0x0D #define JL_CHAR_RETURN 0x0D
#define JL_CHAR_ESCAPE 0x1B #define JL_CHAR_ESCAPE 0x1B
// The FORWARD delete (the key labelled Del/Delete on a PC, ST or Amiga keyboard - not backspace). A text
// editor needs both: backspace removes the character BEFORE the caret, this one removes the character
// UNDER it. 0x7F is ASCII DEL, which is what every one of these keyboards already reports.
#define JL_CHAR_DELETE 0x7F
// Typed-character queue capacity (ring buffer; one slot stays empty, // Typed-character queue capacity (ring buffer; one slot stays empty,
// so JL_CHAR_QUEUE_SIZE - 1 characters can be pending). Must be a // so JL_CHAR_QUEUE_SIZE - 1 characters can be pending). Must be a
@ -114,7 +118,7 @@ void jlInputPoll(void);
// Pop the next typed character as a 7-bit code, or -1 if the queue // Pop the next typed character as a 7-bit code, or -1 if the queue
// is empty. Codes: printable ASCII 0x20..0x7E, plus JL_CHAR_BACKSPACE, // is empty. Codes: printable ASCII 0x20..0x7E, plus JL_CHAR_BACKSPACE,
// JL_CHAR_TAB, JL_CHAR_RETURN, JL_CHAR_ESCAPE. Shift, caps lock, and // JL_CHAR_TAB, JL_CHAR_RETURN, JL_CHAR_ESCAPE, JL_CHAR_DELETE. Shift, caps lock, and
// the keyboard layout are already applied by the backend, so // the keyboard layout are already applied by the backend, so
// punctuation and shifted symbols arrive correctly on every port. // punctuation and shifted symbols arrive correctly on every port.
// (The layout is the machine's own on IIgs/Amiga/ST/X68000; the DOS // (The layout is the machine's own on IIgs/Amiga/ST/X68000; the DOS

View file

@ -167,6 +167,8 @@
#define JL_HAS_SERIAL_WRITE #define JL_HAS_SERIAL_WRITE
#define JL_HAS_SERIAL_AVAILABLE #define JL_HAS_SERIAL_AVAILABLE
#define JL_HAS_SERIAL_FLUSH #define JL_HAS_SERIAL_FLUSH
// Application-driven RTS for a caller doing its own receive-side flow control (jlSerialSetRts).
#define JL_HAS_SERIAL_SET_RTS
// save files + disk space: delete (GS/OS Destroy), dir-ensure (GS/OS Create // save files + disk space: delete (GS/OS Destroy), dir-ensure (GS/OS Create
// $2001, storageType 13), disk-free (GS/OS Volume $2008 via prefix 0). // $2001, storageType 13), disk-free (GS/OS Volume $2008 via prefix 0).
#define JL_HAS_SAVE_DIR_ENSURE #define JL_HAS_SAVE_DIR_ENSURE
@ -353,6 +355,8 @@
#define JL_HAS_SERIAL_WRITE #define JL_HAS_SERIAL_WRITE
#define JL_HAS_SERIAL_AVAILABLE #define JL_HAS_SERIAL_AVAILABLE
#define JL_HAS_SERIAL_FLUSH #define JL_HAS_SERIAL_FLUSH
// Application-driven RTS for a caller doing its own receive-side flow control (jlSerialSetRts).
#define JL_HAS_SERIAL_SET_RTS
// save files + disk space (POSIX mkdir/remove + int21h/36h disk-free) // save files + disk space (POSIX mkdir/remove + int21h/36h disk-free)
#define JL_HAS_SAVE_DIR_ENSURE #define JL_HAS_SAVE_DIR_ENSURE
#define JL_HAS_SAVE_DELETE #define JL_HAS_SAVE_DELETE

View file

@ -101,4 +101,16 @@ int16_t jlSerialReadByte(void);
// Discard any buffered received bytes. // Discard any buffered received bytes.
void jlSerialFlush(void); void jlSerialFlush(void);
// ASSERT OR DROP RTS, for a caller that does its own receive-side flow control.
//
// JL_SERIAL_FLOW_RTSCTS asks the PORT to handshake; this is the other half - the application saying
// "my buffer is nearly full, stop sending" and later "go ahead", which is what a client with a small
// ring and a peer that can be told to pause actually needs. RetroNet's 6551 clients (C64, Apple II)
// have done exactly this for a long time and the 16-bit tier could not, so a JoeyLib target's only
// defence against an overrun was the packet layer's retransmit.
//
// `on` = true asserts RTS (ready to receive). A port with no controllable RTS line no-ops, and a
// closed port no-ops; neither is an error, so a caller can drive it unconditionally.
void jlSerialSetRts(bool on);
#endif #endif

View file

@ -69,7 +69,9 @@ void jlInputCharPush(uint8_t ch) {
uint8_t head; uint8_t head;
uint8_t next; uint8_t next;
if (ch > 0x7E) { // 0x7F is the FORWARD delete, not junk: it is the one code above the printable range the queue
// carries, because a text editor needs a key that removes the character under the caret.
if (ch > 0x7E && ch != JL_CHAR_DELETE) {
return; return;
} }
if (ch < 0x20 && if (ch < 0x20 &&

View file

@ -1011,6 +1011,16 @@ void jlpGenericSerialFlush(void);
#define jlpSerialFlush() jlpGenericSerialFlush() #define jlpSerialFlush() jlpGenericSerialFlush()
#endif #endif
#endif #endif
// Application-driven RTS (jlSerialSetRts). The generic stub does nothing, which is the honest answer
// for a port whose RTS line is not controllable - a caller drives this unconditionally.
void jlpGenericSerialSetRts(bool on);
#if !defined(jlpSerialSetRts)
#if defined(JL_HAS_SERIAL_SET_RTS)
void jlpSerialSetRts(bool on);
#else
#define jlpSerialSetRts(_o) jlpGenericSerialSetRts((_o))
#endif
#endif
// --- Save files + disk space (platform-only; blank-port stub generic + JL_HAS override) --- // --- Save files + disk space (platform-only; blank-port stub generic + JL_HAS override) ---

View file

@ -109,6 +109,14 @@ int16_t jlSerialReadByte(void) {
} }
void jlSerialSetRts(bool on) {
if (!gSerialOpen) {
return;
}
jlpSerialSetRts(on);
}
uint16_t jlSerialWrite(const uint8_t *buf, uint16_t len) { uint16_t jlSerialWrite(const uint8_t *buf, uint16_t len) {
if (!gSerialOpen || buf == NULL || len == 0u) { if (!gSerialOpen || buf == NULL || len == 0u) {
return 0u; return 0u;

View file

@ -93,6 +93,7 @@
// (0xE0 0x1C) and keypad '/' (0xE0 0x35). // (0xE0 0x1C) and keypad '/' (0xE0 0x35).
#define SCAN_ENTER 0x1C #define SCAN_ENTER 0x1C
#define SCAN_SLASH 0x35 #define SCAN_SLASH 0x35
#define SCAN_DELETE 0x53 // shared by keypad '.' and the E0-prefixed Delete key
// Private ISR->poll ring for translated characters. Power of two so // Private ISR->poll ring for translated characters. Power of two so
// the wrap is a mask; holds ISR_CHAR_QUEUE_SIZE - 1 pending bytes. // the wrap is a mask; holds ISR_CHAR_QUEUE_SIZE - 1 pending bytes.
@ -351,10 +352,14 @@ static void keyboardIsr(void) {
// commands, not text, so they produce no character. // commands, not text, so they produce no character.
ch = 0; ch = 0;
if (extended) { if (extended) {
// Of the 0xE0 pairs only keypad Enter and keypad '/' // Of the 0xE0 pairs only keypad Enter, keypad '/' and
// type; the rest (arrows, Home/End, ...) do not. // DELETE type; the rest (arrows, Home/End, ...) do not.
// Delete is the FORWARD delete a text editor needs -
// backspace already types 0x08 from the main block.
if (code == SCAN_ENTER || code == SCAN_SLASH) { if (code == SCAN_ENTER || code == SCAN_SLASH) {
ch = gScanAsciiNormal[code]; ch = gScanAsciiNormal[code];
} else if (code == SCAN_DELETE) {
ch = JL_CHAR_DELETE;
} }
} else if (code >= SCAN_KEYPAD_FIRST && code <= SCAN_KEYPAD_LAST && } else if (code >= SCAN_KEYPAD_FIRST && code <= SCAN_KEYPAD_LAST &&
gIsrNumLock && gKeypadAscii[code - SCAN_KEYPAD_FIRST] != 0) { gIsrNumLock && gKeypadAscii[code - SCAN_KEYPAD_FIRST] != 0) {

View file

@ -278,6 +278,23 @@ bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) {
} }
// RTS on demand (jlSerialSetRts): MCR bit 1. DTR and OUT2 are preserved - OUT2 physically gates this
// UART's interrupt to the PIC, so clearing it while dropping RTS would silence the receiver entirely,
// which is the opposite of what a flow-control pause is for.
void jlpSerialSetRts(bool on) {
uint8_t mcr;
if (gComBase == 0u) {
return;
}
mcr = (uint8_t)(MCR_DTR | MCR_OUT2);
if (on) {
mcr = (uint8_t)(mcr | MCR_RTS);
}
outportb((int)(gComBase + UART_MCR), mcr);
}
void jlpSerialPoll(void) { void jlpSerialPoll(void) {
// The RX IRQ fills the ring in the background; nothing to pump. // The RX IRQ fills the ring in the background; nothing to pump.
} }

View file

@ -34,6 +34,13 @@ void jlpGenericSerialPoll(void) {
} }
// A port with no controllable RTS line: doing nothing is the honest answer, and it lets a caller drive
// jlSerialSetRts unconditionally instead of guarding every call site with a platform test.
void jlpGenericSerialSetRts(bool on) {
(void)on;
}
uint16_t jlpGenericSerialRead(uint8_t *buf, uint16_t max) { uint16_t jlpGenericSerialRead(uint8_t *buf, uint16_t max) {
(void)buf; (void)buf;
(void)max; (void)max;

View file

@ -95,6 +95,10 @@ static uint16_t gRxTail;
static bool gOpen; static bool gOpen;
static bool gIsScc; // true: 8530 SCC; false: 6551 ACIA static bool gIsScc; // true: 8530 SCC; false: 6551 ACIA
static volatile uint8_t *gStatus; // SCC command reg (RR0) / 6551 status reg static volatile uint8_t *gStatus; // SCC command reg (RR0) / 6551 status reg
static uint8_t gWr5; // the SCC's live WR5 (Tx bits + DTR + RTS), so jlpSerialSetRts
// can flip ONE bit without guessing the rest back: WR5 is
// write-only on this chip, and rebuilding it from scratch would
// silently drop the data-bit and Tx-enable settings
static volatile uint8_t *gData; // data register (both) static volatile uint8_t *gData; // data register (both)
@ -219,7 +223,8 @@ static void serialInitScc(volatile uint8_t *ctrl, volatile uint8_t *data, uint8_
sccWrite(ctrl, 13u, (uint8_t)((tc >> 8) & 0xFFu)); // time constant high sccWrite(ctrl, 13u, (uint8_t)((tc >> 8) & 0xFFu)); // time constant high
sccWrite(ctrl, 14u, 0x03u); // BRG enable, source = PCLK sccWrite(ctrl, 14u, 0x03u); // BRG enable, source = PCLK
sccWrite(ctrl, 3u, (uint8_t)(wr3 | 0x01u)); // Rx enable sccWrite(ctrl, 3u, (uint8_t)(wr3 | 0x01u)); // Rx enable
sccWrite(ctrl, 5u, (uint8_t)(wr5 | 0x8Au)); // Tx enable + DTR + RTS gWr5 = (uint8_t)(wr5 | 0x8Au);
sccWrite(ctrl, 5u, gWr5); // Tx enable + DTR + RTS
} }
@ -352,6 +357,23 @@ void iigsSerialPump(void) {
} }
// RTS on demand (jlSerialSetRts). SCC: WR5 bit 1, applied to the retained copy so the Tx enable, DTR and
// data-bit fields survive. 6551: the command register's bits 3:2 - %10 asserts RTS with the receiver
// interrupt disabled (this HAL polls), %00 deasserts it AND gates the transmitter off, which is why the
// deassert is only ever a pause.
void jlpSerialSetRts(bool on) {
if (!gOpen) {
return;
}
if (gIsScc) {
gWr5 = on ? (uint8_t)(gWr5 | 0x02u) : (uint8_t)(gWr5 & (uint8_t)~0x02u);
sccWrite(gStatus, 5u, gWr5);
return;
}
gStatus[1] = on ? 0x09u : 0x01u; // 6551 command: DTR on, RTS asserted / deasserted
}
void jlpSerialPoll(void) { void jlpSerialPoll(void) {
uint16_t tail; uint16_t tail;
uint16_t guard; uint16_t guard;

View file

@ -101,8 +101,12 @@ class Xdf:
continue continue
name = e[0:8].decode("ascii", "replace").rstrip() name = e[0:8].decode("ascii", "replace").rstrip()
ext = e[8:11].decode("ascii", "replace").rstrip() ext = e[8:11].decode("ascii", "replace").rstrip()
# Human68k EXTENDED names: chars 9-18 of the stem live in bytes 12-21 (see encode_name_ext).
# Fold them back in so a listing shows the full name a guest wrote (e.g. the 9-char blob keys).
more = e[12:22].split(b"\x00", 1)[0].decode("ascii", "replace").rstrip()
stem = name + more
out.append({ out.append({
"name": f"{name}.{ext}" if ext else name, "name": f"{stem}.{ext}" if ext else stem,
"cluster": struct.unpack("<H", e[26:28])[0], "cluster": struct.unpack("<H", e[26:28])[0],
"size": struct.unpack("<I", e[28:32])[0], "size": struct.unpack("<I", e[28:32])[0],
"attr": e[11], "attr": e[11],
@ -214,47 +218,81 @@ class Xdf:
self.data[off] = DELETED_MARKER self.data[off] = DELETED_MARKER
return True return True
def add_file(self, name, payload): def _alloc_write_payload(self, payload):
self.delete(name) # overwrite semantics # Allocate a FAT chain for `payload`, write it (zero-padding the final cluster so stale bytes never
need = (len(payload) + self.cluster_bytes - 1) // self.cluster_bytes # leak), and return the head cluster (0 for an empty payload). Shared by add_file and
# add_file_in_subdir so the cluster-writing logic lives in exactly one place.
cb = self.cluster_bytes
need = (len(payload) + cb - 1) // cb
if need == 0:
return 0
free = self.free_clusters() free = self.free_clusters()
if len(free) < need: if len(free) < need:
raise OSError(f"{self.path}: need {need} clusters, {len(free)} free " raise OSError(f"{self.path}: need {need} clusters, {len(free)} free "
f"({len(free) * self.cluster_bytes} bytes)") f"({len(free) * cb} bytes)")
chain = free[:need] chain = free[:need]
for idx, cluster in enumerate(chain): for idx, cluster in enumerate(chain):
off = self.cluster_offset(cluster) off = self.cluster_offset(cluster)
chunk = payload[idx * self.cluster_bytes:(idx + 1) * self.cluster_bytes] chunk = payload[idx * cb:(idx + 1) * cb]
self.data[off:off + len(chunk)] = chunk self.data[off:off + len(chunk)] = chunk
# Zero the tail of the final cluster so stale bytes never leak. if len(chunk) < cb:
if len(chunk) < self.cluster_bytes: self.data[off + len(chunk):off + cb] = bytes(cb - len(chunk))
self.data[off + len(chunk):off + self.cluster_bytes] = \
bytes(self.cluster_bytes - len(chunk))
self.fat_set(cluster, 0xFFF if idx == need - 1 else chain[idx + 1]) self.fat_set(cluster, 0xFFF if idx == need - 1 else chain[idx + 1])
return chain[0]
def _put_entry_in_dir(self, first_cluster, entry):
# Write a 32-byte directory `entry` into the first FREE/DELETED slot of the directory whose data
# begins at first_cluster, extending the cluster chain by one when every slot is already used.
per = self.cluster_bytes // DIR_ENTRY_SIZE
cluster = first_cluster
last = cluster
guard = 0
while 2 <= cluster < EOC_MIN:
base = self.cluster_offset(cluster)
for i in range(per):
slot = base + i * DIR_ENTRY_SIZE
if self.data[slot] in (FREE_MARKER, DELETED_MARKER):
self.data[slot:slot + DIR_ENTRY_SIZE] = entry
return
last = cluster
cluster = self.fat_get(cluster)
guard += 1
if guard > self.max_cluster:
raise OSError(f"{self.path}: directory cluster chain loops")
# Every slot is used: append a fresh, all-free cluster and write into its first slot.
free = self.free_clusters()
if not free:
raise OSError(f"{self.path}: no free cluster to extend directory")
newc = free[0]
self.fat_set(last, newc)
self.fat_set(newc, 0xFFF)
base = self.cluster_offset(newc)
self.data[base:base + self.cluster_bytes] = bytes(self.cluster_bytes)
self.data[base:base + DIR_ENTRY_SIZE] = entry
def add_file(self, name, payload):
self.delete(name) # overwrite semantics
# encode_name_ext writes the Human68k EXTENDED name (a strict superset of 8.3: ext10 is all-NUL
# for a plain 8.3 name), so names up to 18 chars - like RetroNet's 9-char blob keys - land in a
# form the guest and xdftool's own find()/read_file() both resolve.
main11, ext10 = self.encode_name_ext(name)
head = self._alloc_write_payload(payload)
slot = None slot = None
for i, off, e in self._entries(): for _, off, e in self._entries():
if e[0] in (FREE_MARKER, DELETED_MARKER): if e[0] in (FREE_MARKER, DELETED_MARKER):
slot = off slot = off
break break
if slot is None: if slot is None:
raise OSError(f"{self.path}: root directory full ({self.root_entries} entries)") raise OSError(f"{self.path}: root directory full ({self.root_entries} entries)")
self.data[slot:slot + DIR_ENTRY_SIZE] = self._dir_entry(
main11, ATTR_ARCHIVE, head, len(payload), ext10)
entry = bytearray(DIR_ENTRY_SIZE) def _dir_entry(self, name11, attr, cluster, size, ext10=None):
entry[0:11] = self.encode_name(name)
entry[11] = ATTR_ARCHIVE
# Fixed timestamp: reproducible images matter more than real mtimes,
# since these get byte-compared across gate runs.
struct.pack_into("<H", entry, 22, (12 << 11)) # 12:00:00
struct.pack_into("<H", entry, 24, ((2026 - 1980) << 9) | (1 << 5) | 1)
struct.pack_into("<H", entry, 26, chain[0] if need else 0)
struct.pack_into("<I", entry, 28, len(payload))
self.data[slot:slot + DIR_ENTRY_SIZE] = entry
def _dir_entry(self, name11, attr, cluster, size):
entry = bytearray(DIR_ENTRY_SIZE) entry = bytearray(DIR_ENTRY_SIZE)
entry[0:11] = name11 entry[0:11] = name11
entry[11] = attr entry[11] = attr
if ext10 is not None: # Human68k extended name
entry[12:22] = ext10
struct.pack_into("<H", entry, 22, (12 << 11)) # 12:00:00 struct.pack_into("<H", entry, 22, (12 << 11)) # 12:00:00
struct.pack_into("<H", entry, 24, ((2026 - 1980) << 9) | (1 << 5) | 1) # 2026-01-01 struct.pack_into("<H", entry, 24, ((2026 - 1980) << 9) | (1 << 5) | 1) # 2026-01-01
struct.pack_into("<H", entry, 26, cluster) struct.pack_into("<H", entry, 26, cluster)
@ -262,36 +300,34 @@ class Xdf:
return entry return entry
def add_file_in_subdir(self, dirname, name, payload): def add_file_in_subdir(self, dirname, name, payload):
# Create a one-cluster subdirectory in root (with . and .. entries) and write `name` into it, so a # Write `name` into subdirectory `dirname` (creating SUB/. and SUB/.. when the subdir is new), so a
# guest that reads e.g. SAVES/RN.CFG finds it. Fresh-template semantics: the subdir must not exist # guest reading e.g. SAVES/RN.CFG or SAVES/<blob> finds it. If the subdir ALREADY exists (a second
# yet (the callers copy a clean image per run). Human68k FAT12 is otherwise ordinary FAT12. # file staged into the same SAVES/), add INTO it rather than creating a duplicate root entry the
# guest's SUB lookup would never resolve. Human68k FAT12 is otherwise ordinary FAT12.
dstem = dirname.upper() dstem = dirname.upper()
if len(dstem) > 8: if len(dstem) > 8:
raise ValueError(f"subdir '{dirname}' does not fit 8.3") raise ValueError(f"subdir '{dirname}' does not fit 8.3")
cb = self.cluster_bytes main11, ext10 = self.encode_name_ext(name)
need = max(1, (len(payload) + cb - 1) // cb) _, _, de = self.find(dirname)
if de is not None and (de[11] & ATTR_DIR):
head = self._alloc_write_payload(payload)
entry = self._dir_entry(main11, ATTR_ARCHIVE, head, len(payload), ext10)
self._put_entry_in_dir(struct.unpack("<H", de[26:28])[0], entry)
return
# Fresh subdirectory: reserve its cluster first so the payload allocation cannot reuse it, then
# lay down '.', '..', and the file entry.
free = self.free_clusters() free = self.free_clusters()
if len(free) < need + 1: if not free:
raise OSError(f"{self.path}: need {need + 1} clusters, {len(free)} free") raise OSError(f"{self.path}: no free cluster for subdirectory")
dir_cluster = free[0] dir_cluster = free[0]
data_chain = free[1:1 + need] self.fat_set(dir_cluster, 0xFFF)
# File data clusters. head = self._alloc_write_payload(payload)
for idx, cluster in enumerate(data_chain): cb = self.cluster_bytes
off = self.cluster_offset(cluster)
chunk = payload[idx * cb:(idx + 1) * cb]
self.data[off:off + len(chunk)] = chunk
if len(chunk) < cb:
self.data[off + len(chunk):off + cb] = bytes(cb - len(chunk))
self.fat_set(cluster, 0xFFF if idx == need - 1 else data_chain[idx + 1])
# The subdirectory's own cluster: '.', '..', then the file entry, rest free.
doff = self.cluster_offset(dir_cluster) doff = self.cluster_offset(dir_cluster)
self.data[doff:doff + cb] = bytes(cb) self.data[doff:doff + cb] = bytes(cb)
self.data[doff:doff + 32] = self._dir_entry(b". ", ATTR_DIR, dir_cluster, 0) self.data[doff:doff + 32] = self._dir_entry(b". ", ATTR_DIR, dir_cluster, 0)
self.data[doff + 32:doff + 64] = self._dir_entry(b".. ", ATTR_DIR, 0, 0) self.data[doff + 32:doff + 64] = self._dir_entry(b".. ", ATTR_DIR, 0, 0)
self.data[doff + 64:doff + 96] = self._dir_entry( self.data[doff + 64:doff + 96] = self._dir_entry(main11, ATTR_ARCHIVE, head, len(payload), ext10)
self.encode_name(name), ATTR_ARCHIVE, data_chain[0], len(payload))
self.fat_set(dir_cluster, 0xFFF)
# Root entry pointing at the subdirectory.
slot = None slot = None
for _, off, e in self._entries(): for _, off, e in self._entries():
if e[0] in (FREE_MARKER, DELETED_MARKER): if e[0] in (FREE_MARKER, DELETED_MARKER):