summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/index.ts1
-rw-r--r--src/lights/d3aa.ts286
-rw-r--r--src/peripherals/avrdx-adc.ts188
-rw-r--r--src/peripherals/avrdx-ccp.ts29
-rw-r--r--src/peripherals/avrdx-clkctrl.ts72
-rw-r--r--src/peripherals/avrdx-dac.ts30
-rw-r--r--src/peripherals/avrdx-nvmctrl.ts119
-rw-r--r--src/peripherals/avrdx-port-org.ts228
-rw-r--r--src/peripherals/avrdx-port.ts253
-rw-r--r--src/peripherals/avrdx-rstctrl.ts30
-rw-r--r--src/peripherals/avrdx-rtc-pit.ts122
-rw-r--r--src/peripherals/avrdx-sigrow.ts59
-rw-r--r--src/peripherals/avrdx-slpctrl.ts35
-rw-r--r--src/peripherals/avrdx-vref.ts32
-rw-r--r--src/peripherals/avrdx-wdt.ts22
-rw-r--r--src/util.ts24
16 files changed, 1530 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..ec2b3e0
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1 @@
+export { D3AA } from './lights/d3aa';
diff --git a/src/lights/d3aa.ts b/src/lights/d3aa.ts
new file mode 100644
index 0000000..40a390b
--- /dev/null
+++ b/src/lights/d3aa.ts
@@ -0,0 +1,286 @@
+// D3AA Simulator
+// Wires the avr8js CPU with AVR-Dx peripherals to simulate the D3AA flashlight.
+
+// AVR32DD20 register addresses and interrupt vector definitions for the D3AA
+// Sourced from arch/dfp/avrdd/include/avr/ioavr32dd20.h
+
+import { CPU } from 'avr8js/cpu/cpu';
+import { avrInstruction } from 'avr8js/cpu/instruction';
+
+import { loadHex } from '../util';
+
+import { AVRDxCCP } from '../peripherals/avrdx-ccp';
+import { AVRDxPort } from '../peripherals/avrdx-port';
+import { AVRDxDAC } from '../peripherals/avrdx-dac';
+import { AVRDxVREF } from '../peripherals/avrdx-vref';
+import { AVRDxADC } from '../peripherals/avrdx-adc';
+import { AVRDxRTCPIT } from '../peripherals/avrdx-rtc-pit';
+import { AVRDxCLKCTRL } from '../peripherals/avrdx-clkctrl';
+import { AVRDxSLPCTRL } from '../peripherals/avrdx-slpctrl';
+import { AVRDxRSTCTRL } from '../peripherals/avrdx-rstctrl';
+import { AVRDxNVMCTRL } from '../peripherals/avrdx-nvmctrl';
+import { AVRDxSIGROW } from '../peripherals/avrdx-sigrow';
+import { AVRDxWDT } from '../peripherals/avrdx-wdt';
+
+// In avr8js, data[0..31] are the CPU general-purpose registers R0-R31.
+// On AVR-Dx, data addresses 0x0000-0x001F are VPORTs (R0-R31 aren't memory-mapped).
+// This offset is added to all hardware data addresses so peripheral hooks
+// and data storage don't collide with the register file.
+export const DATA_MEMORY_OFFSET = 32;
+
+/*
+ * memory layout
+ * - 0x0000 - 0x13FF I/O
+ * - 0x1400 - 0x14FF EEPROM
+ * - 0x1500 - 0x6FFF unmapped(?)
+ * - 0x7000 - 0x7FFF SRAM
+ * - 0x8000 - 0xFFFF FLASH
+ *
+ * in the virtual CPU (cpu.data) everything is shifted by DATA_MEMORY_OFFSET
+ * to accomodate the registers at data[0..31]; this should probably be
+ * refactored, so that registers are not memory mapped by default and
+ * are only hooked into memory for certain CPUs
+ */
+
+export const EEPROM_START = 0x1400 + DATA_MEMORY_OFFSET;
+export const EEPROM_SIZE = 256;
+
+export const SRAM_START = 0x7000 + DATA_MEMORY_OFFSET;
+export const SRAM_SIZE = 0x1000; // 4 KB
+
+export const MAPPED_PROGMEM_START = 0x8000 + DATA_MEMORY_OFFSET;
+export const FLASH_SIZE = 0x8000; // 32 KB
+export const FLASH_WORDS = FLASH_SIZE / 2;
+
+export const CPU_DATA_SIZE = 0x10000 + DATA_MEMORY_OFFSET;
+
+export const CPU_FREQ = 12_000_000; // 12 MHz default clock
+
+// PORTA pins
+const SWITCH_PIN = 4; // PA4 - e-switch
+// const BATT_LVL_PIN = 5; // PA5 - battery voltage divider (AIN25)
+const BST_ENABLE_PIN = 6; // PA6 - boost regulator enable
+const BUTTON_LED_PIN = 7; // PA7 - button LED
+const AUX_BLUE_PIN = 0; // PA0 - aux blue
+const AUX_GREEN_PIN = 2; // PA2 - aux green
+const AUX_RED_PIN = 3; // PA3 - aux red
+
+// PORTD pins
+const IN_NFET_PIN = 4; // PD4 - startup flash prevention
+const HDR_PIN = 5; // PD5 - high/low current range
+// const DAC_PIN = 6; // PD6 - DAC output
+
+// PORTA_DIR_MASK = (1 << 0) | (1 << 2) | (1 << 3) | (1 << 6) | (1 << 7)
+// PORTD_DIR_MASK = (1 << 4) | (1 << 5) | (1 << 6)
+
+export interface D3AAState {
+ level: number; // brightness level 0-150 (estimated from DAC + VREF + HDR)
+ dac: number; // raw 10-bit DAC value (0-1023)
+ vref: number; // VREF index
+ hdr: number; // HDR FET on/off
+ boost: number; // boost enable on/off
+ nfet: number; // IN- NFET on/off
+ auxR: number; // aux red: 0=off, 1=on
+ auxG: number; // aux green: 0=off, 1=on
+ auxB: number; // aux blue: 0=off, 1=on
+ btnLed: number; // button LED: 0=off, 1=on
+ voltage: number; // battery voltage as vbat*50
+ tempC: number; // temperature in Celsius
+ channel: number; // (not directly readable from hardware, default 0)
+ tickCount: number; // PIT tick counter
+ eeprom: Uint8Array; // 256-byte EEPROM contents
+ cycles: number; // CPU cycle counter
+}
+
+export class D3AA {
+ readonly cpu: CPU;
+ readonly program: Uint16Array;
+
+ // Peripherals
+ readonly ccp: AVRDxCCP;
+ readonly portA: AVRDxPort;
+ readonly portC: AVRDxPort;
+ readonly portD: AVRDxPort;
+ readonly dac: AVRDxDAC;
+ readonly vref: AVRDxVREF;
+ readonly adc: AVRDxADC;
+ readonly pit: AVRDxRTCPIT;
+ readonly clkctrl: AVRDxCLKCTRL;
+ readonly slpctrl: AVRDxSLPCTRL;
+ readonly rstctrl: AVRDxRSTCTRL;
+ readonly nvmctrl: AVRDxNVMCTRL;
+ readonly sigrow: AVRDxSIGROW;
+ readonly wdt: AVRDxWDT;
+
+ // Simulation state
+ private _voltage = 200; // 4.0V default (vbat*50)
+ private _tempC = 25; // 25°C default
+
+ constructor() {
+ this.program = new Uint16Array(FLASH_WORDS);
+ const sramBytes = CPU_DATA_SIZE - 0x100; // registerSpace = 0x100
+ this.cpu = new CPU(this.program, sramBytes, { dataMemoryOffset: DATA_MEMORY_OFFSET, ioOffset: 0 });
+
+ // Set SP to end of SRAM (not end of data array)
+ this.cpu.SP = SRAM_START + SRAM_SIZE - 1; // 0x7FFF
+
+ this.ccp = new AVRDxCCP(this.cpu, 0x0034 + DATA_MEMORY_OFFSET);
+ this.rstctrl = new AVRDxRSTCTRL(this.cpu, 0x0040 + DATA_MEMORY_OFFSET, this.ccp);
+ this.slpctrl = new AVRDxSLPCTRL(this.cpu, 0x0050 + DATA_MEMORY_OFFSET);
+ this.clkctrl = new AVRDxCLKCTRL(this.cpu, 0x0060 + DATA_MEMORY_OFFSET, this.ccp);
+ this.vref = new AVRDxVREF(this.cpu, 0x00B0 + DATA_MEMORY_OFFSET);
+ this.wdt = new AVRDxWDT(this.cpu, 0x0100);
+ this.pit = new AVRDxRTCPIT(this.cpu, 0x0150 + DATA_MEMORY_OFFSET, 6, CPU_FREQ);
+ this.portA = new AVRDxPort(this.cpu, 0x0400 + DATA_MEMORY_OFFSET, 0x00 + DATA_MEMORY_OFFSET, 8);
+ this.portC = new AVRDxPort(this.cpu, 0x0440 + DATA_MEMORY_OFFSET, 0x08 + DATA_MEMORY_OFFSET, 29);
+ this.portD = new AVRDxPort(this.cpu, 0x0460 + DATA_MEMORY_OFFSET, 0x0C + DATA_MEMORY_OFFSET, 24);
+ this.adc = new AVRDxADC(this.cpu, 0x0600 + DATA_MEMORY_OFFSET, 26, this.vref);
+ this.dac = new AVRDxDAC(this.cpu, 0x06A0 + DATA_MEMORY_OFFSET);
+ this.nvmctrl = new AVRDxNVMCTRL(this.cpu, 0x1000 + DATA_MEMORY_OFFSET, 0x1400 + DATA_MEMORY_OFFSET, 256, this.ccp);
+ this.sigrow = new AVRDxSIGROW(this.cpu, 0x1104 + DATA_MEMORY_OFFSET);
+
+ // Handle software reset: re-initialize everything
+ this.rstctrl.onReset = () => {
+ this.cpu.reset();
+ this.cpu.SP = SRAM_START + SRAM_SIZE - 1;
+ };
+
+ // Set initial ADC values
+ this.updateADCInputs();
+
+ // Set switch pin high by default (button not pressed; active-low with pull-up)
+ this.portA.setPin(SWITCH_PIN, true);
+ }
+
+ loadProgram(hex: string) {
+ const u8 = new Uint8Array(this.program.buffer);
+ loadHex(hex, u8);
+ this.cpu.data.set(u8, MAPPED_PROGMEM_START);
+ }
+
+ loadEeprom(data: Uint8Array) {
+ this.nvmctrl.loadEeprom(data);
+ }
+
+ getEepromSnapshot(): Uint8Array {
+ return new Uint8Array(this.nvmctrl.eeprom);
+ }
+
+ /** Run the CPU for the given number of cycles */
+ step(cycles: number) {
+ const target = this.cpu.cycles + cycles;
+ while (this.cpu.cycles < target) {
+ const before = this.cpu.cycles;
+ avrInstruction(this.cpu);
+ const mult = this.clkctrl.cycleMultiplier;
+ if (mult > 1) {
+ this.cpu.cycles += (this.cpu.cycles - before) * (mult - 1);
+ }
+ if (this.slpctrl.sleepUntil > this.cpu.cycles) {
+ this.cpu.cycles = Math.min(this.slpctrl.sleepUntil, target);
+ }
+ this.cpu.tick();
+ }
+ }
+
+ /** Simulate button press (e-switch goes low) */
+ buttonPress() {
+ this.portA.setPin(SWITCH_PIN, false);
+ }
+
+ /** Simulate button release (e-switch goes high) */
+ buttonRelease() {
+ this.portA.setPin(SWITCH_PIN, true);
+ }
+
+ /** Set battery voltage (as vbat * 50, e.g., 200 = 4.0V) */
+ setVoltage(vbat50: number) {
+ this._voltage = vbat50;
+ this.updateADCInputs();
+ }
+
+ /** Set temperature in Celsius */
+ setTemperature(tempC: number) {
+ this._tempC = tempC;
+ this.updateADCInputs();
+ }
+
+ /** Get current simulation state for the web UI */
+ getState(): D3AAState {
+ const portAOut = this.portA.outputValue;
+ const portDOut = this.portD.outputValue;
+
+ return {
+ level: this.estimateLevel(),
+ dac: this.dac.value,
+ vref: this.vref.dacRef,
+ hdr: (portDOut >> HDR_PIN) & 1,
+ boost: (portAOut >> BST_ENABLE_PIN) & 1,
+ nfet: (portDOut >> IN_NFET_PIN) & 1,
+ auxR: this.getAuxState(this.portA, AUX_RED_PIN),
+ auxG: this.getAuxState(this.portA, AUX_GREEN_PIN),
+ auxB: this.getAuxState(this.portA, AUX_BLUE_PIN),
+ btnLed: this.getAuxState(this.portA, BUTTON_LED_PIN),
+ voltage: this._voltage,
+ tempC: this._tempC,
+ channel: 0,
+ tickCount: this.pit.tickCount,
+ eeprom: this.nvmctrl.eeprom,
+ cycles: this.cpu.cycles,
+ };
+ }
+
+ /** Detect 3-state aux LED: 0=off, 1=dim(pullup on input), 2=bright(output high) */
+ private getAuxState(port: AVRDxPort, pin: number): number {
+ const mask = 1 << pin;
+ if (port.dirValue & mask) {
+ // Output mode: high = bright, low = off
+ return (port.outputValue & mask) ? 2 : 0;
+ } else {
+ // Input mode: check if pullup is enabled (dim mode)
+ return port.isPullupEnabled(pin) ? 1 : 0;
+ }
+ }
+
+ private updateADCInputs() {
+ // Voltage: compute physical voltage at ADC pin after divider (330kΩ + 100kΩ)
+ const vbat = this._voltage / 50;
+ this.adc.setVoltagePinV(vbat * 100 / 430);
+
+ // Temperature: use SIGROW calibration to compute raw ADC value
+ this.adc.setRawTemperatureResult(this.sigrow.tempCToRawADC(this._tempC));
+ }
+
+ /** Estimate the Anduril ramp level from DAC + VREF + HDR state.
+ * This is approximate - the real mapping is defined by the PWM tables in the firmware. */
+ private estimateLevel(): number {
+ const dacVal = this.dac.value;
+ const portDOut = this.portD.outputValue;
+ const hdr = (portDOut >> HDR_PIN) & 1;
+ const portAOut = this.portA.outputValue;
+ const boost = (portAOut >> BST_ENABLE_PIN) & 1;
+
+ if (!boost || dacVal === 0) return 0;
+
+ const vref = this.vref.dacRefVolts;
+
+ // Approximate level based on gear system:
+ // Gear 1: Vref=1.024, HDR=0, levels 1-30
+ // Gear 2: Vref=2.500, HDR=0, levels 31-40
+ // Gear 3: Vref=1.024, HDR=1, levels 41-119
+ // Gear 4: Vref=2.500, HDR=1, levels 120-150
+ if (!hdr && vref < 2.0) {
+ // Gear 1: DAC 3-954 → levels 1-30
+ return Math.max(1, Math.min(30, Math.round(1 + (dacVal - 3) * 29 / 951)));
+ } else if (!hdr && vref >= 2.0) {
+ // Gear 2: DAC 434-1023 → levels 31-40
+ return Math.max(31, Math.min(40, Math.round(31 + (dacVal - 434) * 9 / 589)));
+ } else if (hdr && vref < 2.0) {
+ // Gear 3: DAC 20-1018 → levels 41-119
+ return Math.max(41, Math.min(119, Math.round(41 + (dacVal - 20) * 78 / 998)));
+ } else {
+ // Gear 4: DAC 430-1023 → levels 120-150
+ return Math.max(120, Math.min(150, Math.round(120 + (dacVal - 430) * 30 / 593)));
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-adc.ts b/src/peripherals/avrdx-adc.ts
new file mode 100644
index 0000000..34a6f24
--- /dev/null
+++ b/src/peripherals/avrdx-adc.ts
@@ -0,0 +1,188 @@
+// AVR-Dx ADC0 peripheral
+// 12-bit ADC with accumulation, free-running mode, and multiple input sources.
+
+import { type CPU, type AVRInterruptConfig } from 'avr8js/cpu/cpu';
+import { type AVRDxVREF } from './avrdx-vref';
+
+const CTRLA = 0x0000;
+const CTRLB = 0x0001;
+const CTRLC = 0x0002;
+// const CTRLD = 0x0003;
+// const CTRLE = 0x0004;
+// const SAMPCTRL = 0x0006;
+const MUXPOS = 0x0008;
+// const MUXNEG = 0x0009;
+const COMMAND = 0x000A;
+// const EVCTRL = 0x000B;
+const INTCTRL = 0x000C;
+const INTFLAGS = 0x000D;
+// const DBGCTRL = 0x000E;
+// const TEMP = 0x000F;
+const RESL = 0x0010;
+const RESH = 0x0011;
+
+// CTRLA bits
+const ADC_ENABLE_bm = 0x01;
+const ADC_FREERUN_bm = 0x02;
+// const ADC_RESSEL_12BIT_gc = 0x00;
+// const ADC_RESSEL_10BIT_gc = 0x04;
+// const ADC_LEFTADJ_bm = 0x10;
+// const ADC_CONVMODE_SINGLEENDED_gc = 0x00;
+// const ADC_RUNSTBY_bm = 0x80;
+
+// CTRLB accumulation
+// const ADC_SAMPNUM_NONE_gc = 0x00;
+// const ADC_SAMPNUM_ACC2_gc = 0x01;
+// const ADC_SAMPNUM_ACC4_gc = 0x02;
+// const ADC_SAMPNUM_ACC8_gc = 0x03;
+// const ADC_SAMPNUM_ACC16_gc = 0x04;
+// const ADC_SAMPNUM_ACC32_gc = 0x05;
+// const ADC_SAMPNUM_ACC64_gc = 0x06;
+
+// ADC0.COMMAND
+const ADC_STCONV_bm = 0x01;
+
+// ADC0.INTCTRL / INTFLAGS
+const ADC_RESRDY_bm = 0x01;
+
+// ADC MUXPOS special values
+// const ADC_MUXPOS_AIN25_gc = 0x19; // PA5 battery voltage divider
+// const ADC_MUXPOS_GND_gc = 0x40; // Ground
+const ADC_MUXPOS_TEMPSENSE_gc = 0x42; // internal temperature sensor
+// const ADC_MUXPOS_VDDDIV10_gc = 0x44; // VDD/10
+// const ADC_MUXPOS_VDDIO2DIV10_gc = 0x45; // VDDIO2/10
+
+// ADC prescaler (CTRLC)
+// const ADC_PRESC_DIV2_gc = 0x00;
+// const ADC_PRESC_DIV4_gc = 0x01;
+// const ADC_PRESC_DIV8_gc = 0x02;
+// const ADC_PRESC_DIV16_gc = 0x03;
+// const ADC_PRESC_DIV32_gc = 0x04;
+// const ADC_PRESC_DIV64_gc = 0x05;
+// const ADC_PRESC_DIV128_gc = 0x06;
+// const ADC_PRESC_DIV256_gc = 0x07;
+
+
+export class AVRDxADC {
+ /** Voltage at the ADC pin in volts (after external voltage divider) */
+ private voltagePinV = 0;
+ /** Temperature: pre-computed raw accumulated ADC result (from SIGROW calibration) */
+ private temperatureInput = 0;
+ private conversionCallback: (() => void) | null = null;
+
+ private readonly resrdyIrq: AVRInterruptConfig;
+
+ constructor(private cpu: CPU, private base: number, resrdyIrqNo: number, private vref: AVRDxVREF) {
+ this.resrdyIrq = {
+ address: resrdyIrqNo * 2, // vector 26, word addr 0x34
+ flagRegister: base + INTFLAGS,
+ flagMask: ADC_RESRDY_bm,
+ enableRegister: base + INTCTRL,
+ enableMask: ADC_RESRDY_bm,
+ } as const;
+
+ // COMMAND register - writing STCONV starts a conversion
+ cpu.writeHooks[base + COMMAND] = (value) => {
+ cpu.data[base + COMMAND] = value;
+ if (value & ADC_STCONV_bm) {
+ this.startConversion();
+ }
+ return true;
+ };
+
+ // INTCTRL
+ cpu.writeHooks[base + INTCTRL] = (value) => {
+ cpu.data[base + INTCTRL] = value;
+ if (value & ADC_RESRDY_bm) {
+ cpu.updateInterruptEnable(this.resrdyIrq, value);
+ }
+ return true;
+ };
+
+ // INTFLAGS - write 1 to clear
+ cpu.writeHooks[base + INTFLAGS] = (value) => {
+ cpu.data[base + INTFLAGS] &= ~value;
+ if (value & ADC_RESRDY_bm) {
+ cpu.clearInterrupt(this.resrdyIrq);
+ }
+ return true;
+ };
+
+ // RES registers - read only (but firmware can read them)
+ cpu.writeHooks[base + RESL] = () => true; // ignore writes
+ cpu.writeHooks[base + RESH] = () => true;
+ }
+
+ /** Set the voltage at the ADC pin (volts, after external divider).
+ * The ADC result is computed at conversion time from this voltage,
+ * the current VREF selection, and the accumulation count. */
+ setVoltagePinV(volts: number) {
+ this.voltagePinV = volts;
+ }
+
+ /** Set the raw ADC result for temperature (computed by runner with SIGROW values) */
+ setRawTemperatureResult(raw16: number) {
+ this.temperatureInput = raw16;
+ }
+
+ private startConversion() {
+ const ctrla = this.cpu.data[this.base + CTRLA];
+ if (!(ctrla & ADC_ENABLE_bm)) return;
+
+ // Compute approximate conversion time
+ // Prescaler from CTRLC
+ const prescDiv = [2, 4, 8, 16, 32, 64, 128, 256][this.cpu.data[this.base + CTRLC] & 0x07];
+ // Number of accumulated samples
+ const sampNum = this.cpu.data[this.base + CTRLB] & 0x07;
+ const numSamples = sampNum === 0 ? 1 : (1 << sampNum); // 1, 2, 4, 8, 16, 32, 64
+ // Each conversion ~13 ADC clock cycles (plus init delay for first)
+ const adcCycles = 15 * numSamples;
+ const cpuCycles = adcCycles * prescDiv;
+
+ // Schedule completion
+ if (this.conversionCallback) {
+ this.cpu.clearClockEvent(this.conversionCallback);
+ }
+ // TODO: do ADC CPU cycles depend on clock scaling?
+ this.conversionCallback = this.cpu.addClockEvent(() => this.completeConversion(), cpuCycles);
+ }
+
+ private completeConversion() {
+ this.conversionCallback = null;
+
+ const muxpos = this.cpu.data[this.base + MUXPOS];
+ let result: number;
+
+ if (muxpos === ADC_MUXPOS_TEMPSENSE_gc) {
+ // Temperature: use pre-computed accumulated result from SIGROW calibration
+ result = this.temperatureInput;
+ } else {
+ // External pin (voltage divider on AIN25, etc.):
+ // Compute ADC result from physical pin voltage, current VREF, and accumulation
+ const vref = this.vref.adcRefVolts;
+ const sampNum = this.cpu.data[this.base + CTRLB] & 0x07;
+ const numSamples = sampNum === 0 ? 1 : (1 << sampNum);
+ const single = Math.min(4095, Math.max(0, Math.round(this.voltagePinV / vref * 4096)));
+ result = single * numSamples;
+ }
+
+ // Clamp to 16-bit
+ result = Math.max(0, Math.min(0xFFFF, Math.round(result)));
+
+ // Write result
+ this.cpu.data[this.base + RESL] = result & 0xFF;
+ this.cpu.data[this.base + RESH] = (result >> 8) & 0xFF;
+
+ // Clear STCONV
+ this.cpu.data[this.base + COMMAND] &= ~ADC_STCONV_bm;
+
+ // Set RESRDY flag and fire interrupt
+ this.cpu.setInterruptFlag(this.resrdyIrq);
+
+ // Free-running: start another conversion
+ const ctrla = this.cpu.data[this.base + CTRLA];
+ if (ctrla & ADC_FREERUN_bm) {
+ this.startConversion();
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-ccp.ts b/src/peripherals/avrdx-ccp.ts
new file mode 100644
index 0000000..a4f0370
--- /dev/null
+++ b/src/peripherals/avrdx-ccp.ts
@@ -0,0 +1,29 @@
+// CCP - Configuration Change Protection
+// When 0xD8 (IOREG) or 0x9D (SPM) is written to CCP, a 4-cycle window opens
+// during which protected registers can be written.
+
+import type { CPU } from 'avr8js/cpu/cpu';
+
+const ADDR = 0;
+
+const SPM = 0x9D;
+const IOREG = 0xD8;
+
+export class AVRDxCCP {
+ private unlockedUntil = -Infinity;
+
+ constructor(private cpu: CPU, base: number) {
+ cpu.writeHooks[base + ADDR] = (value) => {
+ if (value === IOREG || value === SPM) {
+ // TODO: adjust for cycle scaling(?)
+ this.unlockedUntil = cpu.cycles + 4;
+ }
+ cpu.data[base + ADDR] = value;
+ return true;
+ };
+ }
+
+ isUnlocked(): boolean {
+ return this.cpu.cycles <= this.unlockedUntil;
+ }
+}
diff --git a/src/peripherals/avrdx-clkctrl.ts b/src/peripherals/avrdx-clkctrl.ts
new file mode 100644
index 0000000..91d081b
--- /dev/null
+++ b/src/peripherals/avrdx-clkctrl.ts
@@ -0,0 +1,72 @@
+// AVR-Dx CLKCTRL peripheral
+// Clock controller with CCP-protected writes.
+
+import { type CPU } from 'avr8js/cpu/cpu';
+import { type AVRDxCCP } from './avrdx-ccp';
+
+const MCLKCTRLA = 0x00;
+const MCLKCTRLB = 0x01;
+const MCLKSTATUS = 0x02;
+const OSCHFCTRLA = 0x08;
+
+const PEN_bm = 0x01;
+const PDIV_gm = 0x1E;
+const SOSC_bm = 0x01; // in MCLKSTATUS
+
+export class AVRDxCLKCTRL {
+ /** How many base clocks per instruction clock (1 = no prescaling, 4 = div4, etc.) */
+ cycleMultiplier = 1;
+
+ // PDIV field -> divisor lookup (AVR-Dx datasheet Table 11-1)
+ private static readonly PDIV_DIVISORS: Record<number, number> = {
+ 0: 2, 1: 4, 2: 8, 3: 16, 4: 32, 5: 64,
+ 8: 6, 9: 10, 10: 12, 11: 24, 12: 48,
+ };
+
+ constructor(private cpu: CPU, private base: number, private ccp: AVRDxCCP) {
+ // MCLKCTRLA - CCP protected
+ cpu.writeHooks[base + MCLKCTRLA] = (value) => {
+ if (this.ccp.isUnlocked()) {
+ cpu.data[base + MCLKCTRLA] = value;
+ }
+ return true;
+ };
+
+ // MCLKCTRLB - CCP protected (prescaler)
+ cpu.writeHooks[base + MCLKCTRLB] = (value) => {
+ if (this.ccp.isUnlocked()) {
+ cpu.data[base + MCLKCTRLB] = value;
+ this.updatePrescaler();
+ // Clear SOSC (System Oscillator Changing) flag immediately
+ // (firmware busy-waits on this)
+ cpu.data[base + MCLKSTATUS] &= ~SOSC_bm;
+ }
+ return true;
+ };
+
+ // MCLKSTATUS - read-only
+ cpu.readHooks[base + MCLKSTATUS] = () => {
+ // Always report stable (SOSC = 0)
+ return cpu.data[base + MCLKSTATUS] & ~SOSC_bm;
+ };
+ cpu.writeHooks[base + MCLKSTATUS] = () => true; // ignore writes
+
+ // OSCHFCTRLA - CCP protected
+ cpu.writeHooks[base + OSCHFCTRLA] = (value) => {
+ if (this.ccp.isUnlocked()) {
+ cpu.data[base + OSCHFCTRLA] = value;
+ }
+ return true;
+ };
+ }
+
+ private updatePrescaler() {
+ const val = this.cpu.data[this.base + MCLKCTRLB];
+ if (!(val & PEN_bm)) {
+ this.cycleMultiplier = 1;
+ } else {
+ const pdiv = (val & PDIV_gm) >> 1;
+ this.cycleMultiplier = AVRDxCLKCTRL.PDIV_DIVISORS[pdiv] ?? 2;
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-dac.ts b/src/peripherals/avrdx-dac.ts
new file mode 100644
index 0000000..d4843ca
--- /dev/null
+++ b/src/peripherals/avrdx-dac.ts
@@ -0,0 +1,30 @@
+// AVR-Dx DAC0 peripheral
+// 10-bit DAC with output enable, used for LED brightness control on the D3AA
+
+import type { CPU } from 'avr8js/cpu/cpu';
+
+const CTRLA = 0x00;
+// const DATA = 0x02; // 16-bit (DATAL + DATAH)
+const DATAL = 0x02;
+const DATAH = 0x03;
+
+const ENABLE_bm = 0x01;
+const OUTEN_bm = 0x40;
+
+export class AVRDxDAC {
+ constructor(private cpu: CPU, private base: number) {
+ }
+
+ /** Whether DAC is enabled and output is enabled */
+ get enabled(): boolean {
+ const ctrla = this.cpu.data[this.base + CTRLA];
+ return !!(ctrla & ENABLE_bm) && !!(ctrla & OUTEN_bm);
+ }
+
+ /** Get the current 10-bit DAC value (0-1023).
+ * DAC0.DATA is left-aligned: the 10-bit value sits in bits [15:6]. */
+ get value(): number {
+ const raw16 = this.cpu.data[this.base + DATAL] | (this.cpu.data[this.base + DATAH] << 8);
+ return raw16 >> 6;
+ }
+}
diff --git a/src/peripherals/avrdx-nvmctrl.ts b/src/peripherals/avrdx-nvmctrl.ts
new file mode 100644
index 0000000..2321023
--- /dev/null
+++ b/src/peripherals/avrdx-nvmctrl.ts
@@ -0,0 +1,119 @@
+// AVR-Dx NVMCTRL + Mapped EEPROM
+// EEPROM is memory-mapped at 0x1400-0x14FF and accessed via NVMCTRL commands.
+
+import type { CPU } from 'avr8js/cpu/cpu';
+import type { AVRDxCCP } from './avrdx-ccp';
+
+const CTRLA = 0x0000;
+// const CTRLB = 0x0001;
+// const STATUS = 0x0002;
+// const INTCTRL = 0x0003;
+// const INTFLAGS = 0x0004;
+// const DATAL = 0x0006;
+// const DATAH = 0x0007;
+// const ADDR0 = 0x0008;
+// const ADDR1 = 0x0009;
+// const ADDR2 = 0x000A;
+// const ADDR3 = 0x000B;
+
+// CMD values
+const CMD_NONE_gc = 0x00;
+const CMD_NOOP_gc = 0x01;
+// const CMD_FLWR_gc = 0x02;
+// const CMD_FLPER_gc = 0x08;
+// const CMD_FLMPER2_gc = 0x09;
+// const CMD_FLMPER4_gc = 0x0A;
+// const CMD_FLMPER8_gc = 0x0B;
+// const CMD_FLMPER16_gc = 0x0C;
+// const CMD_FLMPER32_gc = 0x0D;
+const CMD_EEWR_gc = 0x12;
+const CMD_EEERWR_gc = 0x13;
+const CMD_EEBER_gc = 0x18;
+// const CMD_EEMBER2_gc = 0x19;
+// const CMD_EEMBER4_gc = 0x1A;
+// const CMD_EEMBER8_gc = 0x1B;
+// const CMD_EEMBER16_gc = 0x1C;
+// const CMD_EEMBER32_gc = 0x1D;
+
+export class AVRDxNVMCTRL {
+ readonly eeprom: Uint8Array;
+ /** Page buffer for EEPROM writes (tracks which bytes have been written) */
+ private pageBuffer: Uint8Array;
+ private pageBufferDirty: Uint8Array;
+
+ constructor(cpu: CPU, base: number, start: number, private size: number, private ccp: AVRDxCCP, init: undefined | Uint8Array = undefined) {
+ this.eeprom = new Uint8Array(size);
+ this.pageBuffer = new Uint8Array(size);
+ this.pageBufferDirty = new Uint8Array(size);
+
+ this.eeprom.fill(0xFF);
+ if (init) this.loadEeprom(init);
+
+ // CTRLA - CCP protected, executes NVM commands
+ cpu.writeHooks[base + CTRLA] = (value: number) => {
+ if (this.ccp.isUnlocked()) {
+ cpu.data[base + CTRLA] = value;
+ this.executeCommand(value);
+ }
+ return true;
+ };
+
+ // Mapped EEPROM read hooks (0x1400-0x14FF)
+ for (let i = 0; i < size; i++) {
+ cpu.readHooks[start + i] = () => this.eeprom[i];
+
+ // Writes to mapped EEPROM go to the page buffer
+ cpu.writeHooks[start + i] = (value: number) => {
+ this.pageBuffer[i] = value;
+ this.pageBufferDirty[i] = 1;
+
+ // Check the current active command
+ const cmd = cpu.data[base + CTRLA];
+
+ if (cmd === CMD_EEERWR_gc) {
+ // Erase+write: replace byte directly
+ this.eeprom[i] = value;
+ this.pageBufferDirty[i] = 0;
+ } else if (cmd === CMD_EEWR_gc) {
+ // Write-only: AND with existing data (can only clear bits)
+ this.eeprom[i] &= value;
+ this.pageBufferDirty[i] = 0;
+ }
+ return true;
+ };
+ }
+ }
+
+ loadEeprom(data: Uint8Array) {
+ this.eeprom.set(data.subarray(0, this.size));
+ }
+
+ private executeCommand(cmd: number) {
+ switch (cmd) {
+ case CMD_NONE_gc:
+ case CMD_NOOP_gc:
+ this.pageBufferDirty.fill(0);
+ break;
+ case CMD_EEWR_gc:
+ // Write-only mode: subsequent mapped EEPROM writes AND with existing data.
+ // Actual writes happen in the mapped EEPROM write hooks.
+ // Don't clear command — it stays active until NONE/NOOP.
+ break;
+ case CMD_EEERWR_gc:
+ // Erase+write mode: subsequent mapped EEPROM writes replace data directly.
+ // Actual writes happen in the mapped EEPROM write hooks.
+ // Don't clear command — it stays active until NONE/NOOP.
+ break;
+ case CMD_EEBER_gc:
+ // Erase EEPROM page (the page containing the address in ADDR)
+ // For simplicity, erase the whole EEPROM
+ // TODO: figure out page size
+ this.eeprom.fill(0xFF);
+ this.pageBufferDirty.fill(0);
+ break;
+ default:
+ // TODO: implement other commands (if needed)
+ break;
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-port-org.ts b/src/peripherals/avrdx-port-org.ts
new file mode 100644
index 0000000..538384e
--- /dev/null
+++ b/src/peripherals/avrdx-port-org.ts
@@ -0,0 +1,228 @@
+// AVR-Dx VPORT + PORT GPIO peripheral
+// Implements the dual VPORT (fast, 4 regs) + PORT (full, ~24 regs) model.
+
+import { CPU, AVRInterruptConfig } from 'avr8js/dist/esm/cpu/cpu';
+import {
+ VPORT_DIR, VPORT_OUT, VPORT_IN, VPORT_INTFLAGS,
+ PORT_DIR, PORT_DIRSET, PORT_DIRCLR, PORT_DIRTGL,
+ PORT_OUT, PORT_OUTSET, PORT_OUTCLR, PORT_OUTTGL,
+ PORT_IN, PORT_INTFLAGS, PORT_PIN0CTRL,
+ PORT_ISC_gm, PORT_ISC_INTDISABLE_gc, PORT_ISC_BOTHEDGES_gc,
+ PORT_ISC_RISING_gc, PORT_ISC_FALLING_gc, PORT_ISC_LEVEL_gc,
+} from '../d3aa-config';
+
+export interface AVRDxPortConfig {
+ vportBase: number;
+ portBase: number;
+ interrupt: AVRInterruptConfig;
+}
+
+export type PortListener = (dir: number, out: number) => void;
+
+export class AVRDxPort {
+ private pinState = 0x00; // external input state
+ private listeners: PortListener[] = [];
+
+ constructor(
+ private cpu: CPU,
+ private config: AVRDxPortConfig,
+ ) {
+ const { vportBase: vb, portBase: pb } = config;
+
+ cpu.writeHooks[vb + VPORT_DIR] = (value) => {
+ cpu.data[vb + VPORT_DIR] = value;
+ cpu.data[pb + PORT_DIR] = value;
+ return true;
+ };
+
+ cpu.writeHooks[vb + VPORT_OUT] = (value) => {
+ this.setOutput(value);
+ return true;
+ };
+
+ cpu.readHooks[vb + VPORT_IN] = () => {
+ return this.computeInputValue();
+ };
+
+ // VPORT.INTFLAGS - write 1 to clear
+ cpu.writeHooks[vb + VPORT_INTFLAGS] = (value) => {
+ cpu.data[vb + VPORT_INTFLAGS] &= ~value;
+ cpu.data[pb + PORT_INTFLAGS] &= ~value;
+ // If all flags cleared, clear the interrupt
+ if (cpu.data[vb + VPORT_INTFLAGS] === 0) {
+ cpu.clearInterrupt(config.interrupt);
+ }
+ return true;
+ };
+
+ cpu.writeHooks[pb + PORT_DIR] = (value) => {
+ cpu.data[pb + PORT_DIR] = value;
+ cpu.data[vb + VPORT_DIR] = value;
+ return true;
+ };
+
+ // PORT.DIRSET - write 1 to set bits in DIR
+ cpu.writeHooks[pb + PORT_DIRSET] = (value) => {
+ const newDir = cpu.data[pb + PORT_DIR] | value;
+ cpu.data[pb + PORT_DIR] = newDir;
+ cpu.data[vb + VPORT_DIR] = newDir;
+ return true;
+ };
+
+ // PORT.DIRCLR - write 1 to clear bits in DIR
+ cpu.writeHooks[pb + PORT_DIRCLR] = (value) => {
+ const newDir = cpu.data[pb + PORT_DIR] & ~value;
+ cpu.data[pb + PORT_DIR] = newDir;
+ cpu.data[vb + VPORT_DIR] = newDir;
+ return true;
+ };
+
+ // PORT.DIRTGL - write 1 to toggle bits in DIR
+ cpu.writeHooks[pb + PORT_DIRTGL] = (value) => {
+ const newDir = cpu.data[pb + PORT_DIR] ^ value;
+ cpu.data[pb + PORT_DIR] = newDir;
+ cpu.data[vb + VPORT_DIR] = newDir;
+ return true;
+ };
+
+ // PORT.OUT
+ cpu.writeHooks[pb + PORT_OUT] = (value) => {
+ this.setOutput(value);
+ return true;
+ };
+
+ // PORT.OUTSET
+ cpu.writeHooks[pb + PORT_OUTSET] = (value) => {
+ this.setOutput(cpu.data[pb + PORT_OUT] | value);
+ return true;
+ };
+
+ // PORT.OUTCLR
+ cpu.writeHooks[pb + PORT_OUTCLR] = (value) => {
+ this.setOutput(cpu.data[pb + PORT_OUT] & ~value);
+ return true;
+ };
+
+ // PORT.OUTTGL
+ cpu.writeHooks[pb + PORT_OUTTGL] = (value) => {
+ this.setOutput(cpu.data[pb + PORT_OUT] ^ value);
+ return true;
+ };
+
+ // PORT.IN - read returns pin state
+ cpu.readHooks[pb + PORT_IN] = () => {
+ return this.computeInputValue();
+ };
+
+ // PORT.INTFLAGS - write 1 to clear (same as VPORT)
+ cpu.writeHooks[pb + PORT_INTFLAGS] = (value) => {
+ cpu.data[pb + PORT_INTFLAGS] &= ~value;
+ cpu.data[vb + VPORT_INTFLAGS] &= ~value;
+ if (cpu.data[vb + VPORT_INTFLAGS] === 0) {
+ cpu.clearInterrupt(config.interrupt);
+ }
+ return true;
+ };
+
+ // PINnCTRL registers (0x10-0x17)
+ for (let pin = 0; pin < 8; pin++) {
+ cpu.writeHooks[pb + PORT_PIN0CTRL + pin] = (value) => {
+ cpu.data[pb + PORT_PIN0CTRL + pin] = value;
+ return true;
+ };
+ }
+ }
+
+ /** Set an external pin input value */
+ setPin(pin: number, high: boolean) {
+ const oldInput = this.computeInputValue();
+ if (high) {
+ this.pinState |= (1 << pin);
+ } else {
+ this.pinState &= ~(1 << pin);
+ }
+ const newInput = this.computeInputValue();
+ this.checkInterrupts(oldInput, newInput);
+ }
+
+ /** Register a listener for output changes */
+ addListener(listener: PortListener) {
+ this.listeners.push(listener);
+ }
+
+ /** Get the current output register value */
+ get outputValue(): number {
+ return this.cpu.data[this.config.portBase + PORT_OUT];
+ }
+
+ /** Get the current direction register value */
+ get dirValue(): number {
+ return this.cpu.data[this.config.portBase + PORT_DIR];
+ }
+
+ private setOutput(value: number) {
+ const { vportBase: vb, portBase: pb } = this.config;
+ const oldOut = this.cpu.data[pb + PORT_OUT];
+ this.cpu.data[pb + PORT_OUT] = value;
+ this.cpu.data[vb + VPORT_OUT] = value;
+ if (oldOut !== value) {
+ const dir = this.cpu.data[pb + PORT_DIR];
+ for (const listener of this.listeners) {
+ listener(dir, value);
+ }
+ }
+ }
+
+ /** Compute the IN register value: output pins reflect OUT, input pins reflect external state */
+ private computeInputValue(): number {
+ const { portBase: pb } = this.config;
+ const dir = this.cpu.data[pb + PORT_DIR];
+ const out = this.cpu.data[pb + PORT_OUT];
+ // Output pins read back OUT value; input pins read external pin state
+ return (dir & out) | (~dir & this.pinState);
+ }
+
+ private checkInterrupts(oldIn: number, newIn: number) {
+ const { portBase: pb, vportBase: vb } = this.config;
+ const changed = oldIn ^ newIn;
+ if (!changed) return;
+
+ let intFlags = 0;
+ for (let pin = 0; pin < 8; pin++) {
+ if (!(changed & (1 << pin))) continue;
+ const isc = this.cpu.data[pb + PORT_PIN0CTRL + pin] & PORT_ISC_gm;
+ const wasHigh = !!(oldIn & (1 << pin));
+ const isHigh = !!(newIn & (1 << pin));
+
+ let fire = false;
+ switch (isc) {
+ case PORT_ISC_INTDISABLE_gc:
+ break;
+ case PORT_ISC_BOTHEDGES_gc:
+ fire = true;
+ break;
+ case PORT_ISC_RISING_gc:
+ fire = !wasHigh && isHigh;
+ break;
+ case PORT_ISC_FALLING_gc:
+ fire = wasHigh && !isHigh;
+ break;
+ case PORT_ISC_LEVEL_gc:
+ fire = !isHigh; // low level
+ break;
+ }
+ if (fire) {
+ intFlags |= (1 << pin);
+ }
+ }
+
+ if (intFlags) {
+ this.cpu.data[vb + VPORT_INTFLAGS] |= intFlags;
+ this.cpu.data[pb + PORT_INTFLAGS] |= intFlags;
+ // Directly queue the interrupt (bypassing the enable check, since
+ // AVR-Dx port interrupts are enabled per-pin via PINnCTRL ISC bits,
+ // not via a centralized enable register)
+ this.cpu.queueInterrupt(this.config.interrupt);
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-port.ts b/src/peripherals/avrdx-port.ts
new file mode 100644
index 0000000..74ab8ae
--- /dev/null
+++ b/src/peripherals/avrdx-port.ts
@@ -0,0 +1,253 @@
+// AVR-Dx VPORT + PORT GPIO peripheral
+// Implements the dual VPORT (fast, 4 regs) + PORT (full, ~24 regs) model.
+
+import type { CPU, AVRInterruptConfig } from 'avr8js/cpu/cpu';
+
+// VPORT register offsets
+const VPORT_DIR = 0x00;
+const VPORT_OUT = 0x01;
+const VPORT_IN = 0x02;
+const VPORT_INTFLAGS = 0x03;
+
+// PORT register offsets from base
+const DIR = 0x00;
+const DIRSET = 0x01;
+const DIRCLR = 0x02;
+const DIRTGL = 0x03;
+const OUT = 0x04;
+const OUTSET = 0x05;
+const OUTCLR = 0x06;
+const OUTTGL = 0x07;
+const IN = 0x08;
+const INTFLAGS = 0x09;
+// const PORTCTRL = 0x0A;
+const PIN0CTRL = 0x10;
+// PIN1CTRL = 0x11, ..., PIN7CTRL = 0x17
+
+// PINnCTRL bits
+const PULLUPEN_bm = 0x08;
+const ISC_gm = 0x07;
+const ISC_INTDISABLE_gc = 0x00;
+const ISC_BOTHEDGES_gc = 0x01;
+const ISC_RISING_gc = 0x02;
+const ISC_FALLING_gc = 0x03;
+// const ISC_INPUT_DISABLE_gc = 0x04;
+const ISC_LEVEL_gc = 0x05;
+
+export type PortListener = (dir: number, out: number) => void;
+
+export class AVRDxPort {
+ private pinState = 0x00; // external input state
+ private listeners: PortListener[];
+ private irq: AVRInterruptConfig;
+
+ constructor(
+ private cpu: CPU,
+ private base: number,
+ private vbase: number,
+ irqNo: number,
+ ) {
+ this.listeners = [];
+
+ this.irq = {
+ address: irqNo * 2,
+ flagRegister: vbase + VPORT_INTFLAGS,
+ flagMask: 0xFF,
+ enableRegister: base + PIN0CTRL, // dummy; we manage enable ourselves
+ enableMask: 0,
+ constant: true, // don't auto-clear; firmware clears flags manually
+ };
+
+ cpu.writeHooks[vbase + VPORT_DIR] = (value) => {
+ cpu.data[vbase + VPORT_DIR] = value;
+ cpu.data[base + DIR] = value;
+ return true;
+ };
+
+ cpu.writeHooks[vbase + VPORT_OUT] = (value) => {
+ this.setOutput(value);
+ return true;
+ };
+
+ // VPORT.IN - read returns pin state
+ cpu.readHooks[vbase + VPORT_IN] = () => {
+ return this.computeInputValue();
+ };
+
+ // VPORT.INTFLAGS - write 1 to clear
+ cpu.writeHooks[vbase + VPORT_INTFLAGS] = (value) => {
+ cpu.data[vbase + VPORT_INTFLAGS] &= ~value;
+ cpu.data[base + INTFLAGS] &= ~value;
+ // If all flags cleared, clear the interrupt
+ if (cpu.data[vbase + VPORT_INTFLAGS] === 0) {
+ cpu.clearInterrupt(this.irq);
+ }
+ return true;
+ };
+
+ // PORT.DIR
+ cpu.writeHooks[base + DIR] = (value) => {
+ cpu.data[base + DIR] = value;
+ cpu.data[vbase + VPORT_DIR] = value;
+ return true;
+ };
+
+ // PORT.DIRSET - write 1 to set bits in DIR
+ cpu.writeHooks[base + DIRSET] = (value) => {
+ const newDir = cpu.data[base + DIR] | value;
+ cpu.data[base + DIR] = newDir;
+ cpu.data[vbase + VPORT_DIR] = newDir;
+ return true;
+ };
+
+ // PORT.DIRCLR - write 1 to clear bits in DIR
+ cpu.writeHooks[base + DIRCLR] = (value) => {
+ const newDir = cpu.data[base + DIR] & ~value;
+ cpu.data[base + DIR] = newDir;
+ cpu.data[vbase + VPORT_DIR] = newDir;
+ return true;
+ };
+
+ // PORT.DIRTGL - write 1 to toggle bits in DIR
+ cpu.writeHooks[base + DIRTGL] = (value) => {
+ const newDir = cpu.data[base + DIR] ^ value;
+ cpu.data[base + DIR] = newDir;
+ cpu.data[vbase + VPORT_DIR] = newDir;
+ return true;
+ };
+
+ cpu.writeHooks[base + OUT] = (value) => {
+ this.setOutput(value);
+ return true;
+ };
+
+ cpu.writeHooks[base + OUTSET] = (value) => {
+ this.setOutput(cpu.data[base + OUT] | value);
+ return true;
+ };
+
+ cpu.writeHooks[base + OUTCLR] = (value) => {
+ this.setOutput(cpu.data[base + OUT] & ~value);
+ return true;
+ };
+
+ cpu.writeHooks[base + OUTTGL] = (value) => {
+ this.setOutput(cpu.data[base + OUT] ^ value);
+ return true;
+ };
+
+ cpu.readHooks[base + IN] = () => {
+ return this.computeInputValue();
+ };
+
+ // PORT.INTFLAGS - write 1 to clear (same as VPORT)
+ cpu.writeHooks[base + INTFLAGS] = (value) => {
+ cpu.data[base + INTFLAGS] &= ~value;
+ cpu.data[vbase + VPORT_INTFLAGS] &= ~value;
+ if (cpu.data[vbase + VPORT_INTFLAGS] === 0) {
+ cpu.clearInterrupt(this.irq);
+ }
+ return true;
+ };
+
+ // PINnCTRL registers (0x10-0x17)
+ for (let pin = 0; pin < 8; pin++) {
+ cpu.writeHooks[base + PIN0CTRL + pin] = (value) => {
+ cpu.data[base + PIN0CTRL + pin] = value;
+ return true;
+ };
+ }
+ }
+
+ /** Set an external pin input value */
+ setPin(pin: number, high: boolean) {
+ const oldInput = this.computeInputValue();
+ if (high) {
+ this.pinState |= (1 << pin);
+ } else {
+ this.pinState &= ~(1 << pin);
+ }
+ const newInput = this.computeInputValue();
+ this.checkInterrupts(oldInput, newInput);
+ }
+
+ /** Register a listener for output changes */
+ addListener(listener: PortListener) {
+ this.listeners.push(listener);
+ }
+
+ /** Get the current output register value */
+ get outputValue(): number {
+ return this.cpu.data[this.base + OUT];
+ }
+
+ /** Get the current direction register value */
+ get dirValue(): number {
+ return this.cpu.data[this.base + DIR];
+ }
+
+ isPullupEnabled(pin: number): boolean {
+ if (pin < 0 || pin > 7) return false;
+ return !!(this.cpu.data[this.base + PIN0CTRL + pin] & PULLUPEN_bm);
+ }
+
+ private setOutput(value: number) {
+ const oldOut = this.cpu.data[this.base + OUT];
+ this.cpu.data[this.base + OUT] = value;
+ this.cpu.data[this.vbase + VPORT_OUT] = value;
+ if (oldOut !== value) {
+ const dir = this.cpu.data[this.base + DIR];
+ for (const listener of this.listeners) {
+ listener(dir, value);
+ }
+ }
+ }
+
+ /** Compute the IN register value: output pins reflect OUT, input pins reflect external state */
+ private computeInputValue(): number {
+ const dir = this.cpu.data[this.base + DIR];
+ const out = this.cpu.data[this.base + OUT];
+ // Output pins read back OUT value; input pins read external pin state
+ return (dir & out) | (~dir & this.pinState);
+ }
+
+ private checkInterrupts(oldIn: number, newIn: number) {
+ const changed = oldIn ^ newIn;
+ if (!changed) return;
+
+ let intFlags = 0;
+ for (let pin = 0; pin < 8; pin++) {
+ if (!(changed & (1 << pin))) continue;
+ const isc = this.cpu.data[this.base + PIN0CTRL + pin] & ISC_gm;
+ const wasHigh = !!(oldIn & (1 << pin));
+ const isHigh = !!(newIn & (1 << pin));
+
+ let fire = false;
+ switch (isc) {
+ case ISC_INTDISABLE_gc:
+ break;
+ case ISC_BOTHEDGES_gc:
+ fire = true;
+ break;
+ case ISC_RISING_gc:
+ fire = !wasHigh && isHigh;
+ break;
+ case ISC_FALLING_gc:
+ fire = wasHigh && !isHigh;
+ break;
+ case ISC_LEVEL_gc:
+ fire = !isHigh; // low level
+ break;
+ }
+ if (fire) {
+ intFlags |= (1 << pin);
+ }
+ }
+
+ if (intFlags) {
+ this.cpu.data[this.vbase + VPORT_INTFLAGS] |= intFlags;
+ this.cpu.data[this.base + INTFLAGS] |= intFlags;
+ this.cpu.setInterruptFlag(this.irq);
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-rstctrl.ts b/src/peripherals/avrdx-rstctrl.ts
new file mode 100644
index 0000000..370b9c3
--- /dev/null
+++ b/src/peripherals/avrdx-rstctrl.ts
@@ -0,0 +1,30 @@
+// AVR-Dx RSTCTRL - Reset Controller
+// Handles software reset and reset flags.
+
+import { type CPU } from 'avr8js/cpu/cpu';
+import { type AVRDxCCP } from './avrdx-ccp';
+
+export const RSTFR = 0;
+export const SWRR = 1;
+export const SWRST_bm = 0x01;
+
+export class AVRDxRSTCTRL {
+ /** Set this callback to handle software resets */
+ onReset: (() => void) | null = null;
+
+ constructor(cpu: CPU, base: number, ccp: AVRDxCCP) {
+ // RSTFR - reset flags, write 1 to clear
+ cpu.writeHooks[base + RSTFR] = (value) => {
+ cpu.data[base + RSTFR] &= ~value;
+ return true;
+ };
+
+ // SWRR - software reset register (CCP protected)
+ cpu.writeHooks[base + SWRR] = (value) => {
+ if (ccp.isUnlocked() && (value & SWRST_bm)) {
+ if (this.onReset) this.onReset();
+ }
+ return true;
+ };
+ }
+}
diff --git a/src/peripherals/avrdx-rtc-pit.ts b/src/peripherals/avrdx-rtc-pit.ts
new file mode 100644
index 0000000..101f265
--- /dev/null
+++ b/src/peripherals/avrdx-rtc-pit.ts
@@ -0,0 +1,122 @@
+// AVR-Dx RTC Periodic Interrupt Timer (PIT)
+// Generates periodic interrupts from the 32768 Hz internal ULP oscillator.
+
+import type { CPU, AVRInterruptConfig } from 'avr8js/cpu/cpu';
+
+const FREQ_HZ = 32768;
+
+const CTRLA = 0x0;
+const STATUS = 0x1;
+const INTCTRL = 0x2;
+const INTFLAGS = 0x3;
+
+const PI_bm = 0x01;
+const PITEN_bm = 0x01;
+const PERIOD_gm = 0x78; // bits [6:3]
+
+// PIT period to number of 32768Hz clock cycles
+// PERIOD field is bits [6:3] of CTRLA
+export const PERIOD_CYCLES = [
+ 0, // 0x00: OFF
+ 4, // 0x01: CYC4
+ 8, // 0x02: CYC8
+ 16, // 0x03: CYC16
+ 32, // 0x04: CYC32
+ 64, // 0x05: CYC64
+ 128, // 0x06: CYC128
+ 256, // 0x07: CYC256
+ 512, // 0x08: CYC512
+ 1024, // 0x09: CYC1024
+ 2048, // 0x0A: CYC2048
+ 4096, // 0x0B: CYC4096
+ 8192, // 0x0C: CYC8192
+ 16384, // 0x0D: CYC16384
+ 32768, // 0x0E: CYC32768
+ 0,
+] as const;
+
+export class AVRDxRTCPIT {
+ private pitCallback: (() => void) | null = null;
+ private irq: AVRInterruptConfig;
+ tickCount = 0;
+
+ constructor(private cpu: CPU, private base: number, irqNo: number, private cpuFreqHz: number) {
+ this.irq = {
+ address: irqNo * 2,
+ flagRegister: base + INTFLAGS,
+ flagMask: PI_bm,
+ enableRegister: base + INTCTRL,
+ enableMask: PI_bm,
+ }
+
+ // CTRLA - period select + enable
+ cpu.writeHooks[base + CTRLA] = (value) => {
+ cpu.data[base + CTRLA] = value;
+ this.reconfigure();
+ return true;
+ };
+
+ // STATUS - read-only busy flag (always report not busy for simplicity)
+ // TODO: when should this report busy? do we need this?
+ cpu.readHooks[base + STATUS] = () => 0;
+ cpu.writeHooks[base + STATUS] = () => true; // ignore writes
+
+ // INTCTRL - enable interrupt
+ cpu.writeHooks[base + INTCTRL] = (value) => {
+ cpu.data[base + INTCTRL] = value;
+ if (value & PI_bm) {
+ cpu.updateInterruptEnable(this.irq, value);
+ }
+ return true;
+ };
+
+ // INTFLAGS - write 1 to clear
+ cpu.writeHooks[base + INTFLAGS] = (value) => {
+ if (value & PI_bm) {
+ cpu.data[base + INTFLAGS] &= ~PI_bm;
+ cpu.clearInterrupt(this.irq);
+ }
+ return true;
+ };
+ }
+
+ private get cycles() {
+ const ctrla = this.cpu.data[this.base + CTRLA];
+ if (!(ctrla & PITEN_bm)) return;
+ return PERIOD_CYCLES[(ctrla & PERIOD_gm) >> 3] || undefined;
+ }
+
+ // effective tick frequency (Hz), null if disabled
+ get frequency() {
+ const c = this.cycles;
+ return c ? FREQ_HZ / this.cycles : null;
+ }
+
+ private reconfigure() {
+ if (this.pitCallback) {
+ this.cpu.clearClockEvent(this.pitCallback);
+ this.pitCallback = null;
+ }
+
+ const cycles = this.cycles;
+ if (!cycles) return;
+
+ this.scheduleTick(Math.round(cycles * this.cpuFreqHz / FREQ_HZ));
+ }
+
+ private scheduleTick(cycles: number) {
+ this.pitCallback = this.cpu.addClockEvent(() => this.onTick(cycles), cycles);
+ }
+
+ private onTick(cycles: number) {
+ this.tickCount++;
+ this.pitCallback = null;
+
+ this.cpu.setInterruptFlag(this.irq);
+
+ // Re-schedule if still enabled
+ if (this.cpu.data[this.base + CTRLA] & PITEN_bm) {
+ this.scheduleTick(cycles);
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-sigrow.ts b/src/peripherals/avrdx-sigrow.ts
new file mode 100644
index 0000000..ddd266a
--- /dev/null
+++ b/src/peripherals/avrdx-sigrow.ts
@@ -0,0 +1,59 @@
+// AVR-Dx SIGROW - Signature Row
+// Read-only factory calibration data. Pre-loaded with values that produce
+// correct temperature readings with the D3AA firmware's conversion formula.
+
+import { CPU } from 'avr8js/cpu/cpu';
+
+// Typical AVR32DD20 calibration values.
+// The firmware formula (from arch/avr32dd20.c mcu_temp_raw2cooked):
+// temp = (sigrow_offset << 4) - measurement
+// temp *= sigrow_slope
+// temp += 65536 / 8
+// temp >>= 10
+// result is Kelvin << 6
+//
+// IMPORTANT: On AVR, the (sigrow_offset << 4) shift is done in 16-bit
+// arithmetic (int is 16-bit on AVR), so offset MUST be ≤ 0x0FFF (12-bit)
+// or the shift overflows. The datasheet specifies TEMPSENSE1 as a 12-bit value.
+//
+// With slope=0x036F (879), offset=0x0800 (2048):
+// offset << 4 = 32768 (fits in 16 bits)
+// At 25°C: ADC_acc16 ≈ 10548, result = 19081 → 23°C (±2°C rounding)
+// At 80°C: ADC_acc16 ≈ 6447, result = 22601 → 78°C
+// Range -40°C..125°C: ADC values 3092..15394, all in valid 16-bit range
+const DEFAULT_TEMPSENSE0 = 0x036F; // slope
+const DEFAULT_TEMPSENSE1 = 0x0800; // offset (12-bit)
+
+const TEMPSENSE0 = 0;
+const TEMPSENSE1 = 2;
+
+export class AVRDxSIGROW {
+ constructor(cpu: CPU, base: number, readonly slope = DEFAULT_TEMPSENSE0, readonly offset = DEFAULT_TEMPSENSE1) {
+ // TEMPSENSE0 (16-bit at 0x1104-0x1105)
+ cpu.readHooks[base + TEMPSENSE0] = () => slope & 0xFF;
+ cpu.readHooks[base + TEMPSENSE0 + 1] = () => (slope >> 8) & 0xFF;
+
+ // TEMPSENSE1 (16-bit at 0x1106-0x1107)
+ cpu.readHooks[base + TEMPSENSE1] = () => offset & 0xFF;
+ cpu.readHooks[base + TEMPSENSE1 + 1] = () => (offset >> 8) & 0xFF;
+
+ // Write hooks to prevent accidental writes
+ cpu.writeHooks[base + TEMPSENSE0] = () => true;
+ cpu.writeHooks[base + TEMPSENSE0 + 1] = () => true;
+ cpu.writeHooks[base + TEMPSENSE1] = () => true;
+ cpu.writeHooks[base + TEMPSENSE1 + 1] = () => true;
+ }
+
+ /** Compute the raw ADC result (16-bit accumulated) for a given temperature in Celsius */
+ tempCToRawADC(tempC: number): number {
+ const tempK = tempC + 273.15;
+ const kelvin6 = Math.round(tempK * 64);
+ // Reverse the firmware formula:
+ // kelvin6 = ((offset << 4) - measurement) * slope + 8192) >> 10
+ // kelvin6 << 10 = (offset << 4 - measurement) * slope + 8192
+ // (kelvin6 << 10) - 8192 = (offset << 4 - measurement) * slope
+ // measurement = (offset << 4) - ((kelvin6 << 10) - 8192) / slope
+ const measurement = (this.offset << 4) - ((kelvin6 << 10) - 8192) / this.slope;
+ return Math.max(0, Math.min(0xFFFF, Math.round(measurement)));
+ }
+}
diff --git a/src/peripherals/avrdx-slpctrl.ts b/src/peripherals/avrdx-slpctrl.ts
new file mode 100644
index 0000000..93cae58
--- /dev/null
+++ b/src/peripherals/avrdx-slpctrl.ts
@@ -0,0 +1,35 @@
+// AVR-Dx SLPCTRL - Sleep Controller
+// Handles the SLEEP instruction by fast-forwarding to the next clock event.
+
+import type { CPU } from 'avr8js/cpu/cpu';
+
+const CTRLA = 0;
+const SEN_bm = 0x01;
+// const SMODE_gm = 0x06;
+
+export class AVRDxSLPCTRL {
+ sleepUntil: number = 0;
+
+ constructor(private cpu: CPU, private base: number) {
+ // CTRLA - sleep mode + sleep enable
+ cpu.writeHooks[base + CTRLA] = (value) => {
+ cpu.data[base + CTRLA] = value;
+ return true;
+ };
+
+ // Hook the SLEEP instruction
+ cpu.onSleep = () => {
+ this.handleSleep();
+ };
+ }
+
+ private handleSleep() {
+ const ctrla = this.cpu.data[this.base + CTRLA];
+ if (!(ctrla & SEN_bm)) return; // sleep not enabled
+
+ const nextEvent = (this.cpu as any).nextClockEvent;
+ if (nextEvent) {
+ this.sleepUntil = Math.min(this.sleepUntil, nextEvent.cycles);
+ }
+ }
+}
diff --git a/src/peripherals/avrdx-vref.ts b/src/peripherals/avrdx-vref.ts
new file mode 100644
index 0000000..0097096
--- /dev/null
+++ b/src/peripherals/avrdx-vref.ts
@@ -0,0 +1,32 @@
+// AVR-Dx VREF peripheral
+// Voltage reference selection for DAC0 and ADC0
+
+import { CPU } from 'avr8js/cpu/cpu';
+
+const ADC0REF = 0;
+const DAC0REF = 2;
+
+const VREF_VOLTAGES = [1.024, 2.048, 2.500, 4.096] as const;
+
+export class AVRDxVREF {
+ constructor(private cpu: CPU, private base: number) {
+ }
+
+ /** Get DAC Vref selection (raw register value) */
+ get dacRef(): number {
+ return this.cpu.data[this.base + DAC0REF] & 0x07;
+ }
+
+ get dacRefVolts(): number {
+ return VREF_VOLTAGES[this.dacRef] ?? 0;
+ }
+
+ /** Get ADC Vref selection (raw register value) */
+ get adcRef(): number {
+ return this.cpu.data[this.base + ADC0REF] & 0x07;
+ }
+
+ get adcRefVolts(): number {
+ return VREF_VOLTAGES[this.adcRef] ?? 0;
+ }
+}
diff --git a/src/peripherals/avrdx-wdt.ts b/src/peripherals/avrdx-wdt.ts
new file mode 100644
index 0000000..08cd308
--- /dev/null
+++ b/src/peripherals/avrdx-wdt.ts
@@ -0,0 +1,22 @@
+// AVR-Dx SLPCTRL - Sleep Controller
+// Handles the SLEEP instruction by fast-forwarding to the next clock event.
+
+import { type CPU } from 'avr8js/cpu/cpu';
+
+// const CTRLA = 0;
+// const STATUS = 1;
+
+export class AVRDxWDT {
+ constructor(cpu: CPU, base: number) {
+ // firmware writes 0 to disable WDT. We need a write hook so it doesn't crash.
+ // cpu.writeHooks[base + CTRLA] = (value) => {
+ // this.cpu.data[base + CTRLA] = value;
+ // return true;
+ // };
+
+ // cpu.writeHooks[base + STATUS] = (value) => {
+ // this.cpu.data[base + STATUS] = value;
+ // return true;
+ // };
+ }
+}
diff --git a/src/util.ts b/src/util.ts
new file mode 100644
index 0000000..9ef9797
--- /dev/null
+++ b/src/util.ts
@@ -0,0 +1,24 @@
+export class DataFormatError extends Error {};
+
+/*
+ * TODO: remove once node 24 is EOL (2028-05-01?)
+ * node <25 doesn't have Uint8Array.fromHex, so we use Buffer.from(_, "hex") instead
+ */
+type fromHex = (s: string) => Uint8Array | Buffer;
+const fromHex: fromHex = "fromHex" in Uint8Array
+ ? Uint8Array.fromHex as fromHex
+ : (s: string) => Buffer.from(s, "hex");
+
+export function loadHex(source: string, target: Uint8Array) {
+ for (const line of source.split('\n')) {
+ if (line[0] !== ":") continue;
+ if (line.length < 11) throw new DataFormatError("line too short");
+ const data = fromHex(line.slice(1).trimEnd());
+ if (data[3]) return new DataFormatError("unexpected data[3] !== 0");
+ const n = data[0];
+ const addr = (data[1] << 8) | data[2];
+ if (addr + n > target.length) new DataFormatError("target address out of bounds");
+ if (n + 4 !== data.length) new DataFormatError("inconsistent data length");
+ target.set(data.subarray(4), addr);
+ }
+}