#!/usr/bin/env python3
# ============================================================================
# netlist2v.py — generate src/mgpr832.v from mgpr832.net
#
# The register card is 116 ICs of four types wired in a highly regular but
# heavily bit-scrambled pattern (the '373 data pins and the '240/'244 group-2
# pins are deliberately permuted for PCB routing). Transcribing that by hand
# would be error prone and unreviewable, so the structural Verilog is generated
# straight from the KiCad netlist instead: every instance and every connection
# in the output comes from a (comp)/(net) record, not from a reading of the
# schematic.
#
#   python3 tools/netlist2v.py mgpr832.net src/mgpr832.v
#
# Conventions applied to the raw netlist:
#
#   * VCC/GND become 1'b1/1'b0; the models have no supply ports.
#   * A net pulled to VCC through a resistor gets a `pullup`, so a bus whose
#     drivers are all in 3-state idles HIGH as the board's 220R/330R Thevenin
#     terminations make it.
#   * The three 5-pin jumper headers JP1..JP3 become module parameters; see
#     JUMPER_DOC below.
#   * A net with a single node aborts the run: see the check in main().
# ============================================================================

import re
import sys
from collections import OrderedDict

# ---------------------------------------------------------------------------
# Netlist parsing
# ---------------------------------------------------------------------------


def parse(path):
    """Return (ref -> value, net name -> [(ref, pin, pinfunction)])."""
    text = open(path).read()

    comps = OrderedDict()
    for m in re.finditer(r'\(comp\s+\(ref "([^"]+)"\)\s+\(value "([^"]+)"\)', text):
        comps[m.group(1)] = m.group(2)

    nets = OrderedDict()
    for chunk in text[text.index("\t(nets"):].split("\t\t(net\n")[1:]:
        name = re.search(r'\(name "([^"]*)"\)', chunk).group(1)
        nets[name] = [
            (r, int(p), f)
            for r, p, f in re.findall(
                r'\(ref "([^"]+)"\)\s+\(pin "([^"]+)"\)'
                r'(?:\s+\(pinfunction "([^"]*)"\))?\s+\(pintype "[^"]*"\)',
                chunk,
            )
        ]
    return comps, nets


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

JUMPER_DOC = """\
Bank-select straps JP1 (A read port), JP2 (B read port), JP3 (write port).

SEL3 is the fourth select bit, which the '138s have no address input for: it
is a bank select, so two cards can share one 16-register address space. Each
JP is a 3-pin header strapping one input of U117 ('86) HIGH (pin 1, pulled up
through R1) or LOW (pin 3); the port's buffered SEL3 is the XOR's other input.
A LOW strap passes SEL3 through, a HIGH strap inverts it, and the XOR output
is LOW when this card is selected -- so each parameter below is simply the
SEL3 level this card answers to.

There is no "ignore SEL3" setting, so a single-card system must drive SEL3 to
the strapped level rather than leaving it floating."""

HEADER = """\
// ============================================================================
// mgpr832.v — REGISTER CARD, 8 x 32-bit, 1 write port / 2 read ports
//
// GENERATED by tools/netlist2v.py from mgpr832.net — do not edit by hand.
//
// A structural model of the whole card: every IC (74F240, 74F244, 74F373,
// 74F138, 74F86, 74F02) is instantiated from ../74FAST/src and wired exactly
// as the KiCad netlist wires it, so the simulation carries the data sheet
// propagation delays those models specify.
//
// Storage is 32 transparent latches ('373), four per register. A latch's LE is
// the write strobe for its register, active HIGH, so a register is transparent
// while its strobe is asserted and captures on the trailing edge -- this is a
// level-sensitive write, not an edge-triggered one.
//
// Each read port is two inversions deep: the per-register '240s drive an
// internal bus with inverted register data, and a second rank of '240s
// re-inverts it onto Ab/Bb. Both ranks are 3-state, so Ab/Bb float when that
// port's read enable is deasserted. The internal buses are Thevenin terminated
// (220R/330R) and idle HIGH, modelled below as `pullup`.
//
// The output drivers are enabled not by /REA and /REB directly but by the
// bank-qualified AEN/BEN: U117 ('86) strap-selects the SEL3 polarity, U119
// ('02) NORs that with the read strobe, and U118 ('240) inverts and buffers
// the result out to the drivers. A deselected card therefore releases Ab/Bb
// to 3-state instead of driving them, which is what lets two cards share the
// read buses. The '138s are gated by the same term on their E3 inputs, so a
// deselected card also leaves its internal buses quiet.
//
// Data pins are permuted between stages for PCB routing (a register's bit 0 is
// not the '373's d0); the permutation is carried over verbatim from the
// netlist and cancels out by the time data reaches Ab/Bb.
// ============================================================================
`timescale 1ns/100ps"""


# ---------------------------------------------------------------------------
# Pin function -> 74FAST model port name
# ---------------------------------------------------------------------------


# KiCad's generic quad-gate symbols carry no pin names, so the netlist gives
# these parts an empty pinfunction on every pin but the supplies. They are
# mapped by pin number instead, from the standard 14-pin pinouts: both are four
# gates, the '02 with its output first (Y,A,B) and the '86 with it last (A,B,Y).
PIN_MAP = {
    "74F02": {1: "z1", 2: "a1", 3: "b1", 4: "z2", 5: "a2", 6: "b2",
              8: "a3", 9: "b3", 10: "z3", 11: "a4", 12: "b4", 13: "z4"},
    "74F86": {1: "a1", 2: "b1", 3: "z1", 4: "a2", 5: "b2", 6: "z2",
              8: "z3", 9: "a3", 10: "b3", 11: "z4", 12: "a4", 13: "b4"},
}


def port_of(value, pin, pinfunc):
    """Map a netlist pin to a device model port name.

    Most parts here are named after their data sheet pins, so the pinfunction
    (e.g. "I2b_15") is authoritative: the text after the final underscore is
    the pin number it already encodes, so strip it and translate the rest.
    Parts in PIN_MAP have no pin names and are mapped by number. Returns None
    for supply pins, which the models do not have.
    """
    fn = pinfunc.rsplit("_", 1)[0]
    if fn in ("VCC", "GND"):
        return None

    if value in PIN_MAP:
        if pin not in PIN_MAP[value]:
            raise SystemExit("%s has no port for pin %d" % (value, pin))
        return PIN_MAP[value][pin]

    if value in ("74F240", "74F244"):
        # Two independently enabled groups of four buffers. Group 'a' is the
        # models' ports 1_1..1_4, group 'b' is 2_1..2_4; KiCad numbers the pins
        # within each group from 0.
        m = re.fullmatch(r"OE([ab])", fn)
        if m:
            return "oe%d_n" % (1 if m.group(1) == "a" else 2)
        m = re.fullmatch(r"([IO])(\d)([ab])", fn)
        if m:
            return "%s%d_%d" % (
                "d" if m.group(1) == "I" else "o",
                1 if m.group(3) == "a" else 2,
                int(m.group(2)) + 1,
            )

    elif value == "74F373":
        if fn == "OE":
            return "oe_n"
        if fn == "LE":
            return "le"
        m = re.fullmatch(r"([DO])(\d)", fn)
        if m:
            return ("d" if m.group(1) == "D" else "o") + m.group(2)

    elif value == "74F138":
        m = re.fullmatch(r"A(\d)", fn)
        if m:
            return "a" + m.group(1)
        m = re.fullmatch(r"E([12])", fn)
        if m:
            return "e%s_n" % m.group(1)
        if fn == "E3":
            return "e3"
        m = re.fullmatch(r"O(\d)", fn)
        if m:
            return "o%s_n" % m.group(1)

    raise SystemExit("unmapped pin function %r on a %s" % (pinfunc, value))


def natural(s):
    """Sort key that orders a1 < a2 < a10 rather than a1 < a10 < a2."""
    return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", s)]


def net_ident(name):
    """Sanitise a KiCad net name into a Verilog identifier.

    Sheet paths are dropped -- net base names are unique across this design
    once the Y-bus repair is applied -- and KiCad's ~{...} overline becomes the
    project's _n active-low suffix.
    """
    base = name.rsplit("/", 1)[-1]
    suffix = ""
    m = re.fullmatch(r"~\{(.*)\}", base)
    if m:
        base, suffix = m.group(1), "_n"
    return re.sub(r"[^0-9A-Za-z]+", "_", base).strip("_").lower() + suffix


# ---------------------------------------------------------------------------


def main(netpath, outpath):
    comps, nets = parse(netpath)

    # A net with one node is a pin wired to nothing. That is legitimate for the
    # spare connector pins and U104's unused buffer, but it is also exactly how
    # a label-scope mistake shows up -- the 2026-08-05 and -08-07 exports split
    # the write-data bus into 64 such nets, because the connector-side Y labels
    # were root-sheet local while the buffer-side ones were global, which left
    # the card unwritable. Anything dangling that is not an explicit KiCad
    # "unconnected-" placeholder is that class of bug, so refuse to generate.
    dangling = sorted((n for n, nodes in nets.items()
                       if len(nodes) == 1 and not n.startswith("unconnected-")),
                      key=natural)
    if dangling:
        raise SystemExit(
            "%d net(s) have a single node, so something is not joined that "
            "should be -- suspect a local/global label mismatch: %s"
            % (len(dangling), ", ".join(dangling[:8])))

    pin_net = {}   # (ref, pin) -> net name
    pin_func = {}  # (ref, pin) -> pinfunction
    for name, nodes in nets.items():
        for ref, pin, fn in nodes:
            pin_net[(ref, pin)] = name
            pin_func[(ref, pin)] = fn

    ident = {n: net_ident(n) for n in nets
             if n not in ("VCC", "GND") and not n.startswith("unconnected-")}
    assert len(set(ident.values())) == len(ident), "net identifier collision"

    # A net is pulled up if a resistor or resistor network ties it to VCC.
    terminated = []
    for name, nodes in nets.items():
        if name not in ident:
            continue
        for ref, _pin, _fn in nodes:
            if not re.fullmatch(r"RN?\d+", ref):
                continue
            if any(pin_net.get((r, p)) == "VCC"
                   for (r, p) in pin_net if r == ref):
                if name not in terminated:
                    terminated.append(name)

    # ---- top-level ports ---------------------------------------------------
    # Everything reaching a 2x25 card-edge connector (J1..J5) is a card port,
    # looked up by identifier rather than by full net name. A KiCad label's
    # scope decides whether its sheet path appears in the net name -- promoting
    # the Ab/Bb labels to global renamed every "/Sheet 2/Ab0" to "/Ab0" without
    # touching a single connection -- and net_ident() drops the path anyway, so
    # matching on the identifier is what keeps that churn out of this file.
    conn = {}
    for name, nodes in nets.items():
        if name in ident and any(re.fullmatch(r"J\d+", ref) for ref, _p, _f in nodes):
            conn[ident[name]] = name

    def port_bits(base, count):
        bits = []
        for i in range(count):
            key = "%s%d" % (base, i)
            if key not in conn:
                raise SystemExit("no connector net for port bit %s" % key)
            bits.append(conn[key])
        return bits

    def port_scalar(base):
        if base not in conn:
            raise SystemExit("no connector net for port %s" % base)
        return [conn[base]]

    ports = OrderedDict()
    for sel in ("asel", "bsel", "ysel"):
        ports[sel] = ("input", 4, port_bits(sel, 4))
    for strobe in ("rea_n", "reb_n", "wr_n"):
        ports[strobe] = ("input", 1, port_scalar(strobe))
    ports["y"] = ("input", 32, port_bits("y", 32))
    ports["ab"] = ("output", 32, port_bits("ab", 32))
    ports["bb"] = ("output", 32, port_bits("bb", 32))

    # Port nets are referenced through the port, never through a wire.
    port_ref = {}
    for pname, (_d, width, bits) in ports.items():
        for i, b in enumerate(bits):
            port_ref[b] = pname if width == 1 else "%s[%d]" % (pname, i)

    # ---- jumpers -----------------------------------------------------------
    # Each JP is a 3-pin header strapping one XOR input to HIGH (pin 1) or GND
    # (pin 3); the wiper is pin 2. The XOR's other input is that port's
    # buffered SEL3, so the strap chooses which SEL3 polarity selects this
    # card: with the wiper LOW the XOR passes SEL3 and the card answers when
    # SEL3 is LOW, with it HIGH the XOR inverts and the card answers when SEL3
    # is HIGH. Unlike the earlier '138 E2/E3 arrangement there is no "ignore
    # SEL3" setting, so a single-card system has to tie SEL3 to the strapped
    # polarity rather than leaving it floating.
    jumpers = []
    for jp, tag in (("JP1", "A read"), ("JP2", "B read"), ("JP3", "write")):
        jumpers.append((ident[pin_net[(jp, 2)]], "%s_SEL3" % jp,
                        "%s: %s port responds when SEL3 == %s_SEL3" % (jp, tag, jp)))

    # ---- instance grouping -------------------------------------------------
    # The buffered, active-LOW copies of the bank-qualified read enables. The
    # output drivers hang off these rather than off /REAb, /REBb directly --
    # that is the whole point of the bank gate.
    bank_enable = re.compile(r"~\{[AB]EN\}")

    def role(ref):
        value = comps[ref]
        if not value.startswith("74F"):
            return None            # passives, connectors, straps: not instances
        if value in ("74F86", "74F02"):
            return "bankgate"
        if value == "74F138":
            return "decode"
        if value == "74F373":
            return "reg" + re.search(r"WR(\d)", pin_net[(ref, 11)]).group(1)
        if value in ("74F240", "74F244"):
            oe = pin_net[(ref, 1)]
            m = re.search(r"RE[AB](\d)b", oe)
            if m:
                return "reg" + m.group(1)
            if bank_enable.search(oe):
                return "outdrv"
            if any(bank_enable.search(pin_net[(r, p)])
                   for (r, p) in pin_net if r == ref):
                return "bankgate"
            return "inbuf"
        raise SystemExit("unclassified part %s (%s): add it to role()"
                         % (ref, value))

    layout = [("inbuf", "Input buffers: card control lines and the Y write-data bus"),
              ("bankgate", "Bank select: SEL3 polarity strap, qualified with the "
                           "read strobes, buffered out to the output drivers"),
              ("decode", "Address decoding: one active-LOW strobe per register, per port")]
    layout += [("reg%d" % i,
                "Register %d: four '373 latches, and the '240s that read them "
                "onto the A and B buses" % i) for i in range(8)]
    layout += [("outdrv", "Read-bus output drivers: second inversion, A/B -> Ab/Bb")]

    groups = OrderedDict((k, (t, [])) for k, t in layout)
    for ref in comps:
        r = role(ref)
        if r:
            groups[r][1].append(ref)
    for _t, refs in groups.values():
        refs.sort(key=lambda r: int(r[1:]))

    # ---- emit --------------------------------------------------------------
    out = [HEADER, ""]
    w = out.append

    w("module mgpr832 (")
    w(",\n".join("    %-6s wire %s%s" % (d, "" if wd == 1 else "[%d:0] " % (wd - 1), p)
                 for p, (d, wd, _b) in ports.items()))
    w(");")
    w("")
    for line in JUMPER_DOC.splitlines():
        w(("    // " + line).rstrip())
    w("    parameter JP1_SEL3 = 1'b0;   // A read port")
    w("    parameter JP2_SEL3 = 1'b0;   // B read port")
    w("    parameter JP3_SEL3 = 1'b0;   // write port")
    w("")

    rule = "    // " + "-" * 72
    w(rule)
    w("    // Internal nets, named after the netlist nets they carry")
    w(rule)
    for n in sorted((n for n in ident if n not in port_ref),
                    key=lambda s: natural(ident[s])):
        w("    wire %s;" % ident[n])
    w("")
    w("    // Resistor terminations to VCC: a bus with every driver in 3-state")
    w("    // settles HIGH instead of floating.")
    for n in sorted(terminated, key=lambda s: natural(ident[s])):
        w("    pullup (%s);" % port_ref.get(n, ident[n]))
    w("")
    for name, expr, comment in jumpers:
        w("    assign %-14s = %-46s // %s" % (name, expr + ";", comment))
    w("")

    def conn(ref, pin):
        n = pin_net.get((ref, pin))
        if n == "VCC":
            return "1'b1"
        if n == "GND":
            return "1'b0"
        if n is None or n.startswith("unconnected-"):
            return ""
        return port_ref.get(n, ident[n])

    for title, refs in groups.values():
        if not refs:
            continue
        w(rule)
        w("    // " + title)
        w(rule)
        for ref in refs:
            value = comps[ref]
            args = []
            for (r, pin) in sorted((k for k in pin_net if k[0] == ref),
                                   key=lambda k: k[1]):
                p = port_of(value, pin, pin_func[(r, pin)])
                if p is not None:
                    args.append(".%s(%s)" % (p, conn(ref, pin)))
            w("    f%s %s (" % (value[3:], ref.lower()))
            for i in range(0, len(args), 4):
                w("        " + ", ".join(args[i:i + 4]) +
                  ("," if i + 4 < len(args) else ""))
            w("    );")
        w("")

    w("endmodule")
    open(outpath, "w").write("\n".join(out) + "\n")
    print("wrote %s: %d instances, %d nets, %d ports"
          % (outpath, sum(1 for r in comps if comps[r].startswith("74F")),
             len(nets), len(ports)))


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])
