Switch-level simulation and analysis of a photographed die.
A chip traced from photographs is a set of polygons, a set of transistors, and some names. Nothing in it says what the chip does.
halfphi takes that description and gives you two things: a simulation in which
behaviour is an emergent property of switches opening and closing, and analyses
that recover structure from the switch network rather than being told it.
The name is the unit. A chip driven by a two-phase clock does work on both edges, so half of phi is the smallest step that means anything. Counting whole cycles loses half the story.
It is not an emulator. There is no instruction table here and no behavioural
model of anything. If you want to know what opcode $69 does, you run it and
look at what the transistors did.
use halfphi::{parse, ChipSource, Engine, Netlist, Rails};
let parsed = parse(&ChipSource {
segdefs: &std::fs::read_to_string("segdefs.js")?,
transdefs: &std::fs::read_to_string("transdefs.js")?,
nodenames: &std::fs::read_to_string("nodenames.js")?,
// Not a constant: the 6800 calls its ground rail `gnd`, not `vss`.
rails: Rails { ground: "vss", supply: "vcc" },
})?;
let netlist = Netlist::decode(&parsed.blob)?;
println!("{} nodes, {} transistors", netlist.node_count(), netlist.transistor_count());
let mut engine = Engine::new(std::sync::Arc::new(netlist));
engine.force_power_on_state();
engine.restore_layout_pulls();
engine.settle_all();Two runnable examples:
cargo run --example inspect -- path/to/chip-dir # what is in this die
cargo run --example settle -- path/to/chip-dir # power it on and settle it source::parse(&ChipSource) -> a netlist blob + geometry
Netlist::decode(&blob) -> topology: nodes, transistors, names
Engine::new(netlist) -> state: settle, read, drive
Each step is separable on purpose. Topology is immutable, shared and cache-hot; state is per-instance and mutable; and a caller that only wants to analyse a chip never has to instantiate an engine at all.
Adjacency is CSR (compressed sparse row): one flat index array plus per-node offsets, rather than an array-of-arrays. For a chip the size of a 6502 the whole structure is about 90 KiB and stays in L2 during a run.
A node's level is not a property of the node but of the group of nodes currently shorted together through conducting transistors. Settling means rebuilding groups, resolving them, propagating, and repeating to a fixed point.
Group resolution takes the maximum of:
Floating < ChargedHigh < PullDown < PullUp < Vcc < Vss
ChargedHigh is a group with no driver that still contains a node holding
charge. That is how NMOS dynamic logic retains state between clock phases, and
it is why a chip of this era has a minimum clock speed as well as a maximum.
There is no time, capacitance or drive strength here beyond that ordering. It is enough to reproduce a 6502 cycle-exactly. It is not enough for analogue questions, and it is worth knowing which you are asking.
cargo test loads the 6502, the 6800 and the Z80 through identical calls:
| nodes | transistors | names | polygons | rails | |
|---|---|---|---|---|---|
| 6502 | 1725 | 3510 | 846 | 8233 | vss / vcc |
| 6800 | 2944 | 3995 | 1144 | 9805 | gnd / vcc |
| Z80 | 3597 | 6813 | 511 | 14604 | vss / vcc |
The 6502 figures are checked against values known independently. The other two are checked for shape only — nothing here has verified them against an outside source, and they should not be quoted as authoritative.
Two things the second and third chips found, both of which a one-chip library had quietly baked in:
- The 6800 names its ground rail
gnd. Rails are a parameter for this reason. - The 6800's
transdefscarry a seventh field per transistor, a barefalse. The parser had no booleans, having never needed them, and failed at a byte offset rather than anywhere meaningful.
All three converge from a cold power-on, and the Z80 did not always. Until
the solver stopped writing a group's resolved level back into a rail it had
reached, vcc's stored level bounced with whichever group touched it, and each
bounce switched the 32 transistors gated by the supply rail on the Z80 (the
6502 has none), which never settled within the hundred rounds the reference
implementation also capped at. That was recorded here as non-convergence and
attributed to the missing per-chip support.js, with the vcc-gated transistors
written down as an untested lead. The lead was right and the attribution was
wrong. The write was found from the other direction: a reader of a 6502
simulator's node-level export noticed the declared vcc node toggling. The
reference has the same write and the same blindness (its stateString prints
the rails without looking at them), so a differential test could not have seen
it. Rails are now definitions in the solver as well as in the drive rule: never
written, always at their level.
That is a licence boundary as much as a design one. The visual6502 die data is CC BY-NC-SA 3.0, and NonCommercial and ShareAlike propagate to anything that ships it. This crate is MIT and stays MIT by holding none of it: you supply the bytes. See NOTICE.md before you redistribute a build.
Test data comes from a git submodule pointing at upstream visual6502, so this repository does not redistribute it either:
git clone --recurse-submodules https://github.com/tinymachines/halfphi
cargo testWithout the submodule the chip tests skip rather than fail. Set
HALFPHI_REQUIRE_CHIPS=1 to make their absence an error instead, which is what
CI does.
The library stops at the point where a switch network becomes a processor. What a clock edge is, which nodes are pins, what counts as a register, and how a bus handshake works are all facts about a particular chip, and they belong in a crate that is honestly about that chip.
tinymachines/6502 is one worked example:
a 6502 layer on top of this, with a WebGL renderer of the real die, gate
recognition over the switch network, and a decode-PLA analysis. That project is
where halfphi was extracted from.
Early. The API will move. 0.1 is the shape, not a promise.
MIT — see LICENSE and LICENSE-THIRD-PARTY.
