From 1ea55185c3139883999d0de86049f06e815ae83b Mon Sep 17 00:00:00 2001 From: Aaron-Hartwig Date: Fri, 21 Aug 2026 06:36:42 -0500 Subject: [PATCH 1/5] rdl: update adoc gen for flat maps, fix mem in html --- docs/BSV_MIGRATION_EXAMPLES.md | 10 +- tools/fpga_releaser/archive_parser.py | 2 +- tools/fpga_releaser/cli.py | 4 +- tools/hdl_common.bzl | 47 +++-- tools/rdl.bzl | 20 +- tools/site_cobble/rdl_pkg/exporter.py | 44 +++-- .../rdl_pkg/templates/regmap_adoc.jinja2 | 175 ++++++++++++++++-- .../rdl_pkg/templates/regmap_html.jinja2 | 9 +- tools/site_cobble/rdl_pkg/utils.py | 56 ++++++ 9 files changed, 284 insertions(+), 83 deletions(-) diff --git a/docs/BSV_MIGRATION_EXAMPLES.md b/docs/BSV_MIGRATION_EXAMPLES.md index 6be9608a..49a93444 100644 --- a/docs/BSV_MIGRATION_EXAMPLES.md +++ b/docs/BSV_MIGRATION_EXAMPLES.md @@ -330,7 +330,10 @@ bsv_bluesim_tests( ### Key Changes -1. **RDL rule**: `rdl()` → `rdl_file()`, `sources` → `src` (singular) +1. **RDL rule**: `rdl()` → `rdl_file()`, `sources` → `src` (singular). Buck2 requires + the target name to end in `_rdl` and the `src` basename to match it, so + `rdl('foo_registers', sources = ['foo.rdl'])` becomes + `rdl_file(name = "foo_rdl", src = "foo.rdl")`. 2. **Load statements**: Added both `bsv.bzl` and `rdl.bzl` imports 3. **Dependency structure**: Remains identical (Buck2 handles transitive deps automatically) 4. **Generated files**: Same syntax for referencing generated BSV files (`:I2CCoreRegsPkg#I2CCoreRegs.bsv`) @@ -495,7 +498,7 @@ bsv_library( ) rdl_file( - name = "ignition_controller_registers", + name = "ignition_controller_rdl", src = "ignition_controller.rdl", outputs = [ "IgnitionControllerRegisters.bsv", @@ -508,10 +511,9 @@ rdl_file( bsv_library( name = "ControllerRegisters", srcs = [ - ":ignition_controller_registers#IgnitionControllerRegisters.bsv", + ":ignition_controller_rdl[bsv]", ], deps = [ - ":ignition_controller_registers", "//hdl/ip/bsv:RegCommon", ], ) diff --git a/tools/fpga_releaser/archive_parser.py b/tools/fpga_releaser/archive_parser.py index 12467373..63dcf777 100644 --- a/tools/fpga_releaser/archive_parser.py +++ b/tools/fpga_releaser/archive_parser.py @@ -27,7 +27,7 @@ def get_relevant_files_from_buck_zip(fpga_name, zip): zip_names.append(item.filename) if item.filename.endswith(".bit"): zip_names.append(item.filename) - if "maps/" in item.filename and (item.filename.endswith(".json") or item.filename.endswith(".html")): + if "maps/" in item.filename and item.filename.endswith((".json", ".html", ".adoc")): zip_names.append(item.filename) if item.filename.endswith("nextpnr.log"): zip_names.append(item.filename) diff --git a/tools/fpga_releaser/cli.py b/tools/fpga_releaser/cli.py index bc0fb525..ae654d8d 100644 --- a/tools/fpga_releaser/cli.py +++ b/tools/fpga_releaser/cli.py @@ -24,7 +24,9 @@ parser.add_argument("--skip-gh", default=False, action="store_true", help="Skip doing GH release. Note that doing this still generates release metadata that just will be wrong") parser.add_argument("--zip", default=None, help="Path to zip file to use instead of downloading from GitHub") -hubris_ignore = [".html", ".log", ".rpt"] +# Register map docs go to the GH release but not into hubris, which has no +# consumer for them. +hubris_ignore = [".html", ".adoc", ".log", ".rpt"] def main(): """ diff --git a/tools/hdl_common.bzl b/tools/hdl_common.bzl index 183703e3..1fa96c4f 100644 --- a/tools/hdl_common.bzl +++ b/tools/hdl_common.bzl @@ -15,45 +15,40 @@ def rdl_project_as_args(value: Artifact): RDLTSet = transitive_set(args_projections={"args": rdl_project_as_args}) RDLFileInfo = provider(fields={"set": provider_field(RDLTSet)}) -RDLHtmlMaps = provider(fields=["files"]) -RDLJsonMaps = provider(fields=["files"]) +RDLDocMaps = provider(fields=["files"]) RDLBSVPkgs = provider(fields=["files"]) +# Extensions copied next to a bitstream in maps/. Kept explicit rather than +# "everything in RDLDocMaps" so adding a new doc format stays a deliberate +# decision about what ships with a build. Pairs with the filter in +# tools/fpga_releaser/archive_parser.py. +_MAPS_DIR_EXTENSIONS = [".json", ".html", ".adoc"] + def propagate_rdl_maps(deps): - """Collect RDL map providers from deps and return providers to propagate them.""" - providers = [] - html_maps = [] - json_maps = [] + """Collect RDL doc-map providers from deps and return providers to propagate them.""" + files = [] for x in deps: - if x.get(RDLHtmlMaps): - html_maps.extend(x[RDLHtmlMaps].files) - if x.get(RDLJsonMaps): - json_maps.extend(x[RDLJsonMaps].files) - if len(html_maps) > 0: - providers.append(RDLHtmlMaps(files=html_maps)) - if len(json_maps) > 0: - providers.append(RDLJsonMaps(files=json_maps)) - return providers + if x.get(RDLDocMaps): + files.extend(x[RDLDocMaps].files) + if len(files) > 0: + return [RDLDocMaps(files=files)] + return [] def collect_rdl_maps(ctx, dep): - """Copy RDL map files from dep into a maps/ output subdirectory. + """Copy RDL doc-map files from dep into a maps/ output subdirectory. Returns a list of copy artifacts suitable for use as hidden inputs to force Buck2 to materialize them. """ maps = [] - json_maps = dep.get(RDLJsonMaps) - if json_maps != None: - for file in set(json_maps.files): - new_file = ctx.actions.declare_output("maps", file.basename) - maps.append(ctx.actions.copy_file(new_file, file)) - html_maps = dep.get(RDLHtmlMaps) - if html_maps != None: - for file in set(html_maps.files): - new_file = ctx.actions.declare_output("maps", file.basename) - maps.append(ctx.actions.copy_file(new_file, file)) + doc_maps = dep.get(RDLDocMaps) + if doc_maps == None: + return maps + for file in set([f for f in doc_maps.files if f.extension in _MAPS_DIR_EXTENSIONS]): + new_file = ctx.actions.declare_output("maps", file.basename) + maps.append(ctx.actions.copy_file(new_file, file)) return maps diff --git a/tools/rdl.bzl b/tools/rdl.bzl index 6c83b973..12d88a19 100644 --- a/tools/rdl.bzl +++ b/tools/rdl.bzl @@ -20,8 +20,7 @@ load( "HDLFileInfo", "HDLFileInfoTSet", "VHDLFileInfo", - "RDLHtmlMaps", - "RDLJsonMaps", + "RDLDocMaps", "RDLBSVPkgs", ) @@ -60,7 +59,7 @@ def _rdl_file_impl(ctx): # In general, our convention is . but we want "_pkg" to be appended # in the VHDL case. BSV allows flexible naming to support CamelCase package names. for out in ctx.attrs.outputs: - # Allow .vhd, .bsv, .json, and .html outputs in buck + # Allow .vhd, .bsv, .json, .html, and .adoc outputs in buck if out.endswith(".vhd"): expected_name = src_base_name + "_pkg.vhd" if out != expected_name: @@ -77,8 +76,12 @@ def _rdl_file_impl(ctx): expected_name = src_base_name + ".html" if out != expected_name: fail("HTML output {} does not match expected filename {}".format(out, expected_name)) + elif out.endswith(".adoc"): + expected_name = src_base_name + ".adoc" + if out != expected_name: + fail("AsciiDoc output {} does not match expected filename {}".format(out, expected_name)) else: - fail("Output {} does not have an expected extension (.vhd, .bsv, .json, .html)".format(out)) + fail("Output {} does not have an expected extension (.vhd, .bsv, .json, .html, .adoc)".format(out)) # Get the rdl python executable since we'll be using it for # for generating our outputs rdl_gen_py = ctx.attrs._rdl_gen[RunInfo] @@ -109,12 +112,9 @@ def _rdl_file_impl(ctx): all_gen_vhdl = ctx.actions.tset(HDLFileInfoTSet, children=gen_vhdl_tset) providers.append(HDLFileInfo(set_all=all_gen_vhdl)) - html_maps = [x for x in outs if x.extension == ".html"] - if len(html_maps) > 0: - providers.append(RDLHtmlMaps(files=html_maps)) - json_maps = [x for x in outs if x.extension == ".json"] - if len(json_maps) > 0: - providers.append(RDLJsonMaps(files=json_maps)) + doc_maps = [x for x in outs if x.extension in [".html", ".json", ".adoc"]] + if len(doc_maps) > 0: + providers.append(RDLDocMaps(files=doc_maps)) bsv_pkgs = [x for x in outs if x.extension == ".bsv"] if len(bsv_pkgs) > 0: providers.append(RDLBSVPkgs(files=bsv_pkgs)) diff --git a/tools/site_cobble/rdl_pkg/exporter.py b/tools/site_cobble/rdl_pkg/exporter.py index e9a8fdcb..1885e534 100644 --- a/tools/site_cobble/rdl_pkg/exporter.py +++ b/tools/site_cobble/rdl_pkg/exporter.py @@ -15,12 +15,26 @@ try: from models import Register, Field, ReservedField, Memory from listeners import BaseListener - from utils import to_camel_case, to_snake_case, vhdl_2008_bitstring + from utils import ( + to_camel_case, + to_snake_case, + vhdl_2008_bitstring, + adoc_inline, + adoc_cell, + adoc_para, + ) loader = FileSystemLoader(Path(__file__).parent / "templates") except: from rdl_pkg.models import Register, Field, ReservedField, Memory from rdl_pkg.listeners import BaseListener - from rdl_pkg.utils import to_camel_case, to_snake_case, vhdl_2008_bitstring + from rdl_pkg.utils import ( + to_camel_case, + to_snake_case, + vhdl_2008_bitstring, + adoc_inline, + adoc_cell, + adoc_para, + ) loader = PackageLoader("rdl_pkg") from typing import Any, Dict, List @@ -78,7 +92,9 @@ def __init__(self, **kwargs): self.env.filters["to_camel_case"] = to_camel_case self.env.filters["to_snake_case"] = to_snake_case self.env.filters["vhdl_2008_bitstring"] = vhdl_2008_bitstring - self.templates = [] + self.env.filters["adoc_inline"] = adoc_inline + self.env.filters["adoc_cell"] = adoc_cell + self.env.filters["adoc_para"] = adoc_para self.outputs = [] def _write_files(self, context): @@ -89,14 +105,6 @@ def _write_files(self, context): class MapofMapsExporter(BaseExporter): - def __init__(self, **kwargs): - super().__init__(**kwargs) - # Sort of a hack for now, load our jinja templates into a list - self.templates = [ - self.env.get_template("toplvl_bsv.jinja2"), - self.env.get_template("regmap_html.jinja2"), - ] - def export( self, node: Node, output_names: List[PathLike], **kwargs: "Dict[str, Any]" ) -> None: @@ -138,20 +146,15 @@ def export( "isinstance": isinstance, "registers": addr_map.registers, "flatten_names": True, + # The register list is flat, so templates that want a block's name, + # desc or base address need the top node to look the child up. + # register.node.owning_addrmap is the innermost map, not the block. + "map_node": node, } self._write_files(context) class MapExporter(BaseExporter): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.templates = [ - # self.env.get_template('regmap_adoc.jinja2'), - self.env.get_template("regpkg_bsv.jinja2"), - self.env.get_template("regmap_html.jinja2"), - self.env.get_template("regpkg_vhdl.jinja2"), - ] - def export( self, node: Node, output_names: List[PathLike], **kwargs: "Dict[str, Any]" ) -> None: @@ -191,6 +194,7 @@ def export( "isinstance": isinstance, "registers": addr_map.registers, "flatten_names": False, + "map_node": node, } self._write_files(context) diff --git a/tools/site_cobble/rdl_pkg/templates/regmap_adoc.jinja2 b/tools/site_cobble/rdl_pkg/templates/regmap_adoc.jinja2 index d15818d0..34fbdcfa 100644 --- a/tools/site_cobble/rdl_pkg/templates/regmap_adoc.jinja2 +++ b/tools/site_cobble/rdl_pkg/templates/regmap_adoc.jinja2 @@ -1,26 +1,163 @@ -{% macro top() -%} - {% for register in registers %} -{{ register.get_property("name") }} -[caption="Address: "] -.{{ "{0:#06x}".format(register.offset) }} - {{ register.name }} Register -[cols=4,options="header"] +{#- + # This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at https://mozilla.org/MPL/2.0/. + # + # AsciiDoc register map documentation. Used for every .adoc output of the RDL + # exporter, both for a single address map (MapExporter) and for a map of maps + # (MapofMapsExporter). + # + # Context, from rdl_pkg/exporter.py: + # map_name instance name of the top address map + # map_node systemrdl node of the top address map + # registers flat, address ordered list of Register and Memory models + # flatten_names true for a map of maps: names are prefixed with the block + # isinstance, Register, Memory, ReservedField for type dispatch + # + # Everything between the tag::body and end::body markers is a self contained + # fragment, so a hand written document can pull the tables in with + # include::.adoc[tag=body,leveloffset=+1] +-#} +{% set anchor_root = map_name %} +{% set reg_h = '===' if flatten_names else '==' %} +{#- Identifier for a register/memory: block qualified in a map of maps -#} +{% macro reg_name(register) -%} +{{ register.prefixed_name if flatten_names else register.name }} +{%- endmacro %} +{% macro reg_id(register) -%} +reg-{{ anchor_root }}-{{ reg_name(register) }} +{%- endmacro %} +{#- Bits/Access/Reset/Field/Description, plus one table per distinct encoding -#} +{% macro field_table(register) %} + +[cols="1,1,1,3,8",options="header"] +|=== +| Bits | Access | Reset | Field | Description +{% for field in register.fields %} +{% if isinstance(field, ReservedField) %} +| {{ field.text_bitslice_str() }} | - | - | - | _Reserved_ +{% else %} +{% set reset = field.get_property('reset') %} +{% set encode_note %}{% if field.has_encode() %} + +Encoded as `{{ field.get_property('encode').type_name }}`, see below.{% endif %}{% endset %} +| {{ field.text_bitslice_str() }} | {{ field.get_property('sw').name }} | {{ "{:#x}".format(reset) if reset is not none else '-' }} | `{{ field.name }}` | {{ field.desc | adoc_cell }}{{ encode_note }} +{% endif %} +{% endfor %} +|=== +{% set done = namespace(encodings=[]) %} +{% for field in register.encoded_fields %} +{% set encode = field.get_property('encode') %} +{% if encode.type_name not in done.encodings %} +{% set _ = done.encodings.append(encode.type_name) %} + +.Encoding `{{ encode.type_name }}` +[cols="1,3,8",options="header"] +|=== +| Value | Name | Description +{% for member in encode %} +| `{{ "{:#x}".format(member.value) }}` | `{{ member.name }}` | {{ member.rdl_desc | adoc_cell }} +{% endfor %} +|=== +{% endif %} +{% endfor %} +{%- endmacro %} +{#- One section per register or memory -#} +{% macro register_section(register) %} + +[#{{ reg_id(register) }}] +{% set friendly = register.get_property('name') | adoc_inline %} +{% set friendly = '' if friendly == register.node.inst_name else friendly %} +{{ reg_h }} {{ reg_name(register) }}{{ ' (' ~ friendly ~ ')' if friendly else '' }} + +{% if isinstance(register, Memory) %} +Offset `{{ "{:#06x}".format(register.offset) }}`, {{ register.get_property('mementries') }} entries of {{ register.get_property('memwidth') }} bits ({{ register.node.size }} bytes), access `{{ register.get_property('sw').name }}`. +{% else %} +Offset `{{ "{:#06x}".format(register.offset) }}`, {{ register.width }} bits, reset {% if register.has_reset_definition %}`{{ "0x{:0{}x}".format(register.elaborated_reset, register.width // 4) }}`{% else %}unspecified{% endif %}. +{% endif %} +{% if register.get_property('desc') %} + +{{ register.get_property('desc') | adoc_para }} +{% endif %} +{% if not isinstance(register, Memory) %} +{{ field_table(register) -}} +{% endif %} +{%- endmacro %} +{#- Blocks (top level children of a map of maps) in address order -#} +{% set blocks = namespace(names=[]) %} +{% set counts = namespace(memories=0) %} +{% for register in registers %} +{% if isinstance(register, Memory) %} +{% set counts.memories = counts.memories + 1 %} +{% endif %} +{% if flatten_names and register.prefix and register.prefix[0] not in blocks.names %} +{% set _ = blocks.names.append(register.prefix[0]) %} +{% endif %} +{% endfor %} +// This is a generated file using the RDL tooling. Do not edit by hand. +:showtitle: +:toc: left +:toclevels: 2 +:numbered: +:icons: font +:sectanchors: +:table-caption!: + += {{ map_name }} register map + +// tag::body[] +{% if map_node and map_node.get_property('desc') %} +{{ map_node.get_property('desc') | adoc_para }} + +{% endif %} +{{ registers | length - counts.memories }} registers{% if counts.memories == 1 %} and one memory{% elif counts.memories %} and {{ counts.memories }} memories{% endif %}{% if flatten_names %}, in {{ blocks.names | length }} blocks{% endif %}. Every offset is a byte address relative to the base of the `{{ map_name }}` address map. +{% if flatten_names %} + +== Block map + +[cols="2,1,5",options="header"] |=== -| Bits | SW Access | Name | Function - {% for field in register.fields %} -|[{{ field.bsv_bitslice_str() }}] | {{field.get_property('sw').name}} | {{ field.name }} | {{ field.desc }} - {% endfor %} +| Block | Base | Description +{% for block in blocks.names %} +{% set node = map_node.get_child_by_name(block) if map_node else none %} +| <> | `{{ "{:#06x}".format(node.absolute_address) if node else '-' }}` | {{ (node.get_property('name') if node else '') | adoc_cell }} +{% endfor %} |=== +{% endif %} - {% endfor %} -{% endmacro %} +== Register summary + +[cols="1,4,5",options="header"] +|=== +| Offset | Register | Description +{% for register in registers %} +| `{{ "{:#06x}".format(register.offset) }}` | <<{{ reg_id(register) }},{{ reg_name(register) }}>> | {% if isinstance(register, Memory) %}Memory, {{ register.get_property('mementries') }} x {{ register.get_property('memwidth') }} bits{% else %}{{ register.get_property('name') | adoc_cell }}{% endif %} + +{% endfor %} +|=== +{% if flatten_names %} +{% for block in blocks.names %} +{% set node = map_node.get_child_by_name(block) if map_node else none %} -Test Output: +[#blk-{{ anchor_root }}-{{ block }}] +{% set friendly = (node.get_property('name') if node else '') | adoc_inline %} +{% set friendly = '' if friendly == block else friendly %} +== {{ block }}{{ ' (' ~ friendly ~ ')' if friendly else '' }} +{% if node %} -{{ top() }} +Base address `{{ "{:#06x}".format(node.absolute_address) }}`. +{% endif %} +{% if node and node.get_property('desc') %} +{{ node.get_property('desc') | adoc_para }} +{% endif %} +{% for register in registers if register.prefix and register.prefix[0] == block %} +{{ register_section(register) }} +{% endfor %} +{% endfor %} +{% else %} +{% for register in registers %} +{{ register_section(register) }} +{% endfor %} +{% endif %} -{#.Power Device Signal Routing#} -{#[cols=7,options="header"]#} -{#|===#} -{#| Bits | Name | Function#} -{#|===#} +// end::body[] diff --git a/tools/site_cobble/rdl_pkg/templates/regmap_html.jinja2 b/tools/site_cobble/rdl_pkg/templates/regmap_html.jinja2 index 52862bd6..fb9ffefc 100644 --- a/tools/site_cobble/rdl_pkg/templates/regmap_html.jinja2 +++ b/tools/site_cobble/rdl_pkg/templates/regmap_html.jinja2 @@ -292,6 +292,9 @@  {{register.get_property("name")}} {% endif %} + {# Memories have no fields, and their width is the whole memory, so + fields_by_bytes would emit one empty row per byte of the memory. #} + {% if not isinstance(register, Memory) %} {# Loop over fields in bytes#} {% set outer_loop = loop %} {% for slice, byte_view in register.fields_by_bytes %} @@ -325,8 +328,9 @@ {{field.name}} {{field.text_bitslice_str() }} {{field.get_property('sw').name}} - {{field.reset_str}} - {{field.desc}} + {% set reset = field.get_property('reset') %} + {{ "{:#0x}".format(reset) if reset is not none else '' }} + {{ field.desc or '' }} {% if field.has_encode() %}

@@ -338,6 +342,7 @@ {% endif %} {% endfor %} + {% endif %} {% endfor %} diff --git a/tools/site_cobble/rdl_pkg/utils.py b/tools/site_cobble/rdl_pkg/utils.py index 3e6fd7d0..3b424ce9 100644 --- a/tools/site_cobble/rdl_pkg/utils.py +++ b/tools/site_cobble/rdl_pkg/utils.py @@ -25,3 +25,59 @@ def vhdl_2008_bitstring(template_string, size): else: val = template_string return f'{size}x"{val:x}"' + + +def _adoc_lines(value, escape_pipes=False): + """ + Split a SystemRDL string property into stripped lines. + + RDL desc properties are routinely written as multi-line strings, so every + continuation line carries the source indentation. None becomes an empty + list rather than the string "None". + """ + if value is None: + return [] + lines = [line.strip() for line in str(value).splitlines()] + if escape_pipes: + lines = [line.replace("|", "\\|") for line in lines] + return lines + + +def adoc_inline(value, default=""): + """ + Collapse a property to a single line, for use in an AsciiDoc section title + where a line break would end the title. + """ + lines = [line for line in _adoc_lines(value) if line] + return " ".join(lines) if lines else default + + +def adoc_cell(value, default=""): + """ + Render a property as one AsciiDoc PSV table cell. + + An unescaped '|' would start a new cell and shift every following cell in + the row, so pipes are escaped. Lines join with the AsciiDoc hard line break + (' +') so the structure of things like enumerated bit encodings survives, + and blank lines are dropped so a row can never be split early. + """ + lines = [line for line in _adoc_lines(value, escape_pipes=True) if line] + return " +\n".join(lines) if lines else default + + +def adoc_para(value, default=""): + """ + Render a property as AsciiDoc body text outside a table: hard line breaks + within a paragraph, blank lines between paragraphs. Pipes are left alone + since they are not special outside a table. + """ + paragraphs, current = [], [] + for line in _adoc_lines(value): + if line: + current.append(line) + elif current: + paragraphs.append(" +\n".join(current)) + current = [] + if current: + paragraphs.append(" +\n".join(current)) + return "\n\n".join(paragraphs) if paragraphs else default From 4fc0bcbdb23afa93d2861bdf52ec081feccbc40a Mon Sep 17 00:00:00 2001 From: Aaron-Hartwig Date: Fri, 21 Aug 2026 06:37:08 -0500 Subject: [PATCH 2/5] cosmo_seq: add adoc regmap to releases --- hdl/projects/cosmo_seq/BUCK | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hdl/projects/cosmo_seq/BUCK b/hdl/projects/cosmo_seq/BUCK index ed481ad6..c58fbd34 100644 --- a/hdl/projects/cosmo_seq/BUCK +++ b/hdl/projects/cosmo_seq/BUCK @@ -17,7 +17,8 @@ rdl_file( "//hdl/ip/vhd/i2c/io_expanders/PCA9506ish:pca9506_regs_rdl", ], outputs = [ - "cosmo_seq_top.html", + "cosmo_seq_top.adoc", + "cosmo_seq_top.html", "cosmo_seq_top.json" ] ) From 3d7ae6fdf4b4933ec2bc535b5346a2972d935c81 Mon Sep 17 00:00:00 2001 From: Aaron-Hartwig Date: Fri, 21 Aug 2026 14:33:02 -0500 Subject: [PATCH 3/5] bsv: propagate RDL register maps into BSV bitstreams A bsv_library that referenced its RDL package only as srcs = [":x_rdl[bsv]"] silently dropped the register-map docs. propagate_rdl_maps reads ctx.attrs.deps, and srcs entries are bare Artifacts with no providers attached, so RDLDocMaps never entered the graph and the bitstream came out with no maps/ directory. minibar and qsfp_x32 already listed the rdl target in both srcs and deps, so they were fine. gimlet_sequencer, gimlet_sdle_only and both sidecar mainboard revisions were not. Add the missing deps edge to the three bsv_library targets involved. Since the failure is silent and easy to repeat, bsv_library now checks for it: if a srcs artifact is owned by a target whose name ends in _rdl and that target is absent from deps, analysis fails with a message saying what to add. rdl.bzl already enforces the _rdl suffix on every rdl_file target, so the name check is as reliable as that convention. The migration guide was teaching the broken pattern, which is how this spread; fix the example and spell out why the rdl target belongs in both srcs and deps. Also dedup in propagate_rdl_maps. It accumulated a plain list, so artifacts repeated once per distinct dep path -- qsfp_x32 carried the same two artifacts ten times. Harmless downstream because collect_rdl_maps dedups by artifact, but unbounded in principle. maps/ copy actions per bitstream, before -> after: gimlet_sequencer, gimlet_sdle_only 0 -> 2 each minibar_controller 2 -> 4 mainboard rev_b, rev_cd 0 -> 4 each qsfp_x32 2 -> 2 8x ignition_target/psc (no RDL) 0 -> 0 VHDL bitstreams unchanged: cosmo_seq 23, grapefruit 16, cosmo_hp 8. --- docs/BSV_MIGRATION_EXAMPLES.md | 24 ++++++++++++++++++++--- hdl/ip/bsv/ignition/BUCK | 1 + hdl/projects/gimlet/sequencer/BUCK | 1 + hdl/projects/sidecar/mainboard/BUCK | 1 + tools/bsv.bzl | 30 +++++++++++++++++++++++++++++ tools/hdl_common.bzl | 4 +++- 6 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/BSV_MIGRATION_EXAMPLES.md b/docs/BSV_MIGRATION_EXAMPLES.md index 49a93444..8a5567a3 100644 --- a/docs/BSV_MIGRATION_EXAMPLES.md +++ b/docs/BSV_MIGRATION_EXAMPLES.md @@ -514,6 +514,7 @@ bsv_library( ":ignition_controller_rdl[bsv]", ], deps = [ + ":ignition_controller_rdl", "//hdl/ip/bsv:RegCommon", ], ) @@ -845,12 +846,29 @@ rdl_file( # BSV library uses only the .bsv output via sub-target bsv_library( name = "MyRegs", - srcs = [":my_regs_rdl[bsv]"], # ← Use [bsv] sub-target, not full target - deps = ["//hdl/ip/bsv:RegCommon"], + srcs = [":my_regs_rdl[bsv]"], # <- Use [bsv] sub-target, not full target + deps = [ + ":my_regs_rdl", # <- ALSO depend on the full target, see below + "//hdl/ip/bsv:RegCommon", + ], ) ``` -**Common mistake:** Using the full RDL target causes BSC to try compiling `.html` and `.json` files. +**Common mistake #1:** Using the full RDL target in `srcs` causes BSC to try compiling +`.html` and `.json` files. Only the `[bsv]` sub-target belongs there. + +**Common mistake #2:** Omitting the RDL target from `deps`. The RDL target must appear +in **both** places, and they do different jobs: + +- `srcs = [":my_regs_rdl[bsv]"]` gives BSC the generated `.bsv` and nothing else. +- `deps = [":my_regs_rdl"]` carries the *providers*. `srcs` entries are plain + artifacts with no providers attached, so this is the only edge that propagates + `RDLDocMaps` — the register-map docs that `collect_rdl_maps` copies into the + `maps/` directory next to a bitstream. Leave it out and the bitstream silently + ships with no register maps. + +`bsv_library` fails analysis if you forget, so this is enforced rather than +convention. ### Issue: Custom bsc_flags not working diff --git a/hdl/ip/bsv/ignition/BUCK b/hdl/ip/bsv/ignition/BUCK index b75c3725..3c071f03 100644 --- a/hdl/ip/bsv/ignition/BUCK +++ b/hdl/ip/bsv/ignition/BUCK @@ -126,6 +126,7 @@ bsv_library( name = "ControllerRegisters", srcs = [":ignition_controller_rdl[bsv]"], deps = [ + ":ignition_controller_rdl", "//hdl/ip/bsv:RegCommon", ], ) diff --git a/hdl/projects/gimlet/sequencer/BUCK b/hdl/projects/gimlet/sequencer/BUCK index 8262307b..0a10b0dc 100644 --- a/hdl/projects/gimlet/sequencer/BUCK +++ b/hdl/projects/gimlet/sequencer/BUCK @@ -29,6 +29,7 @@ bsv_library( name = "GimletSeqFpgaRegs", srcs = [":gimlet_seq_fpga_regs_rdl[bsv]"], deps = [ + ":gimlet_seq_fpga_regs_rdl", "//hdl/ip/bsv:RegCommon", ], ) diff --git a/hdl/projects/sidecar/mainboard/BUCK b/hdl/projects/sidecar/mainboard/BUCK index 73a3c3f0..9dde2a8f 100644 --- a/hdl/projects/sidecar/mainboard/BUCK +++ b/hdl/projects/sidecar/mainboard/BUCK @@ -24,6 +24,7 @@ bsv_library( ":sidecar_mainboard_controller_rdl[bsv]", ], deps = [ + ":sidecar_mainboard_controller_rdl", "//hdl/ip/bsv:RegCommon", ], ) diff --git a/tools/bsv.bzl b/tools/bsv.bzl index fac3837a..ae8aca98 100644 --- a/tools/bsv.bzl +++ b/tools/bsv.bzl @@ -9,9 +9,39 @@ load(":bsv_common.bzl", "BSVFileInfo", "BSVLibraryInfo", "BSVVerilogInfo", "BSVS load(":hdl_common.bzl", "RDLBSVPkgs", "propagate_rdl_maps", "collect_rdl_maps") # Toolchain accessed via RunInfo - no custom provider needed +def _check_rdl_srcs_are_deps(ctx: AnalysisContext): + """Require an rdl_file referenced from srcs to also be in deps. + + RDL register-map docs ride on the RDLDocMaps provider, and srcs entries arrive + as bare Artifacts that carry no providers. Referencing :foo_rdl[bsv] from srcs + without also listing :foo_rdl in deps silently drops the docs, and the bitstream + ends up with no maps/ directory. Catch it here rather than three rules + downstream, where the only symptom is a missing file. + """ + dep_targets = [d.label.raw_target() for d in ctx.attrs.deps] + for src in ctx.attrs.srcs: + # Source files and unbound artifacts have no owner to check. + if src.is_source or src.owner == None: + continue + # rdl.bzl enforces the _rdl suffix on every rdl_file target, so a name + # check is exactly as reliable as that naming convention. + if not src.owner.name.endswith("_rdl"): + continue + if src.owner.raw_target() not in dep_targets: + fail( + ("{}: srcs references `{}`, generated by `{}`, but `{}` is not in " + + "deps. Add it to deps (keep the [bsv] sub-target in srcs) so its " + + "RDL register-map docs reach the bitstream's maps/ directory.") + .format(ctx.label, src.basename, src.owner.raw_target(), + src.owner.raw_target()), + ) + + def _bsv_library_impl(ctx: AnalysisContext) -> list[Provider]: """Compile BSV sources to .bo object files""" + _check_rdl_srcs_are_deps(ctx) + # Get toolchain bsc = ctx.attrs._toolchain[RunInfo] diff --git a/tools/hdl_common.bzl b/tools/hdl_common.bzl index 1fa96c4f..32c70d6e 100644 --- a/tools/hdl_common.bzl +++ b/tools/hdl_common.bzl @@ -32,7 +32,9 @@ def propagate_rdl_maps(deps): if x.get(RDLDocMaps): files.extend(x[RDLDocMaps].files) if len(files) > 0: - return [RDLDocMaps(files=files)] + # Dedup at every level so the list can't grow with the number of distinct + # dep paths through a diamond-shaped graph. + return [RDLDocMaps(files=list(set(files)))] return [] From 316b2a62a47b8537b73669d644a7aac63dc39c12 Mon Sep 17 00:00:00 2001 From: Aaron-Hartwig Date: Fri, 21 Aug 2026 14:34:01 -0500 Subject: [PATCH 4/5] hdl: materialize register maps via DefaultInfo, not hidden inputs The maps/ directory next to a bitstream was not a declared output of anything. It only appeared because collect_rdl_maps' copy artifacts were passed as hidden inputs to place-and-route (BSV) or synthesis (yosys, vivado) -- a hack the code called out as "a bit sketchy". Two real consequences: CI could publish stale or missing maps. The bitstream jobs run on self-hosted runners with clean: false to preserve buck-out, and upload dirname($OUTPUT), i.e. the whole target directory. On a warm cache the place-and-route action is a hit, so the copies need never re-materialize and whatever was in maps/ from a previous run gets uploaded. Editing a register description forced a full place-and-route, because hidden inputs are part of the action digest. On the vivado path it forced re-synthesis and everything after it. Return the copies as DefaultInfo other_outputs plus a [maps] sub-target instead, so materialization is a property of building the target. In yosys.bzl and vivado.bzl collect_rdl_maps is called from the synthesis helper rather than the rule impl, so the maps ride out on that step's own DefaultInfo and get re-exported by the bitstream rule; calling collect_rdl_maps a second time would double-declare the outputs. Only maps come off the hidden lists. in_json_file stays: it is what makes synthesis re-run when source file *contents* change, and that was a tricky bug to find the first time. Verified: a cold build in a throwaway isolation dir (0% cache hits) materializes maps/; aquery confirms the nextpnr and icepack actions no longer take maps as inputs; rebuilding sidecar mainboard after its maps gained a file completed in 2 commands with place-and-route cached, where before it would have forced a fresh ECP5 P&R. buck2 build --show-output still prints the bitstream as field 2, which .github/workflows/build.yml depends on. --- tools/bsv.bzl | 12 ++++++++++-- tools/vivado.bzl | 24 +++++++++++++++--------- tools/yosys.bzl | 22 ++++++++++++++-------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/tools/bsv.bzl b/tools/bsv.bzl index ae8aca98..a2328b6d 100644 --- a/tools/bsv.bzl +++ b/tools/bsv.bzl @@ -557,7 +557,7 @@ def _bsv_nextpnr_ice40_bitstream_impl(ctx: AnalysisContext) -> list[Provider]: pnr_log = ctx.actions.declare_output("nextpnr.log") # Run nextpnr-ice40 - pnr_cmd = cmd_args(hidden = maps) + pnr_cmd = cmd_args() pnr_cmd.add(ctx.attrs._nextpnr_ice40[RunInfo]) pnr_cmd.add("--{}".format(ctx.attrs.family)) # e.g., --up5k pnr_cmd.add("--package", ctx.attrs.package) # e.g., sg48 @@ -589,9 +589,13 @@ def _bsv_nextpnr_ice40_bitstream_impl(ctx: AnalysisContext) -> list[Provider]: return [ DefaultInfo( default_output = bit_file, + # Register maps are not inputs to anything, so they need to be named + # here or buck2 would never build them. + other_outputs = maps, sub_targets = { "asc": [DefaultInfo(default_output = asc_file)], "json": [DefaultInfo(default_output = yosys_json)], + "maps": [DefaultInfo(default_outputs = maps)], } ), ] @@ -628,7 +632,7 @@ def _bsv_nextpnr_ecp5_bitstream_impl(ctx: AnalysisContext) -> list[Provider]: pnr_log = ctx.actions.declare_output("nextpnr.log") # Run nextpnr-ecp5 - pnr_cmd = cmd_args(hidden = maps) + pnr_cmd = cmd_args() pnr_cmd.add(ctx.attrs._nextpnr_ecp5[RunInfo]) pnr_cmd.add("--{}".format(ctx.attrs.family)) # e.g., --25k, --45k, --85k pnr_cmd.add("--package", ctx.attrs.package) # e.g., CABGA381, CSFBGA285 @@ -660,9 +664,13 @@ def _bsv_nextpnr_ecp5_bitstream_impl(ctx: AnalysisContext) -> list[Provider]: return [ DefaultInfo( default_output = bit_file, + # Register maps are not inputs to anything, so they need to be named + # here or buck2 would never build them. + other_outputs = maps, sub_targets = { "config": [DefaultInfo(default_output = config_file)], "json": [DefaultInfo(default_output = yosys_json)], + "maps": [DefaultInfo(default_outputs = maps)], } ), ] diff --git a/tools/vivado.bzl b/tools/vivado.bzl index 1cb3a216..5940a597 100644 --- a/tools/vivado.bzl +++ b/tools/vivado.bzl @@ -56,10 +56,15 @@ def _vivado_bitstream(ctx): router = route(ctx, placer_opt) bits = bitstream(ctx, router) compressed = compress_bitstream(ctx, bits) + # Register maps come out of the synth step as other_outputs; re-export them + # here so a plain `buck2 build` of the bitstream materializes maps/. + maps = synth[0].other_outputs return [ DefaultInfo( default_output=compressed[0].default_outputs[0], + other_outputs=maps, sub_targets = { + "maps": [DefaultInfo(default_outputs=maps)], "synth": synth, "opt": opt, "place": placer, @@ -106,15 +111,16 @@ def synthesize(ctx): report = ctx.actions.declare_output("{}.rpt".format(name_and_flow)) # Build vivado command - # Collect register maps into maps/ output directory + # Collect register maps into maps/ output directory. Nothing downstream + # consumes these, so they ride out on this step's DefaultInfo as + # other_outputs and get re-exported by the bitstream rule. They are + # deliberately NOT hidden inputs to synthesis: that would make every + # register description edit re-run synthesis and everything after it, and it + # would leave materialization dependent on the synthesis action missing the + # cache. maps = collect_rdl_maps(ctx, ctx.attrs.top) - # This is a bit sketchy but we're declaring any maps as hidden inputs - # here to force the generation of these files since nothing downstream - # depends on them. Buck2 is too smart such that since nothing depends - # on them, it doesn't even build them - # This is a bit of a hack but it works for now. - # The in_json_file is also a hidden input since it is generated + # The in_json_file is a hidden input since it is generated # This was a tricky source of bugs. We need to make sure synthesis # runs, *any* time the source files change (obviously). However, the # way we're doing this is generating .tcl file for vivado to run in @@ -129,14 +135,14 @@ def synthesize(ctx): # as a hidden input to the synthesis step. This will guarantee that changes # to file contents will be in caught and synthesis will re-run as expected. - vivado = _make_vivado_common(ctx, name_and_flow, vivado_flow_tcl, hidden=[in_json_file, maps]) + vivado = _make_vivado_common(ctx, name_and_flow, vivado_flow_tcl, hidden=[in_json_file]) # Add output files to tclargs vivado.add("-tclargs", checkpoint.as_output(), report.as_output()) # Run vivado ctx.actions.run(vivado, category="vivado_{}".format(flow)) - providers.append(DefaultInfo(default_output=checkpoint)) + providers.append(DefaultInfo(default_output=checkpoint, other_outputs=maps)) return providers diff --git a/tools/yosys.bzl b/tools/yosys.bzl index d0945c46..a0deb6cd 100644 --- a/tools/yosys.bzl +++ b/tools/yosys.bzl @@ -20,12 +20,17 @@ def _ice40_bitstream_impl(ctx): next_pnr_providers = ice40_nextpnr(ctx, yosys_synth_providers) icepack_providers = icepack(ctx, next_pnr_providers) compressed = compress_bitstream(ctx, icepack_providers) + # Register maps come out of the synth step as other_outputs; re-export them + # here so a plain `buck2 build` of the bitstream materializes maps/. + maps = yosys_synth_providers[0].other_outputs return [ DefaultInfo( default_output=compressed[0].default_outputs[0], + other_outputs=maps, sub_targets = { "synth": yosys_synth_providers, "route": next_pnr_providers, + "maps": [DefaultInfo(default_outputs=maps)], } ) ] @@ -46,14 +51,15 @@ def yosys_vhdl_synth(ctx): } in_json_file = ctx.actions.write_json("yosys_synth_input.json", out_json, with_inputs=True) - # Collect register maps into maps/ output directory + # Collect register maps into maps/ output directory. Nothing downstream + # consumes these, so they ride out on this step's DefaultInfo as + # other_outputs and get re-exported by the bitstream rule. They are + # deliberately NOT hidden inputs to synthesis: that would make every + # register description edit re-run synthesis and place-and-route, and it + # would leave materialization dependent on the synthesis action missing the + # cache. maps = collect_rdl_maps(ctx, ctx.attrs.top) - # This is a bit sketchy but we're declaring any maps as hidden inputs - # here to force the generation of these files since nothing downstream - # depends on them. Buck2 is too smart such that since nothing depends - # on them, it doesn't even build them - # This is a bit of a hack but it works for now. yosys_py = ctx.actions.declare_output("synth.py") yosys_gen = ctx.attrs._yosys_gen[RunInfo] @@ -67,7 +73,7 @@ def yosys_vhdl_synth(ctx): yosys_synth_log = ctx.actions.declare_output("synth.log") yosys_ghdl_warns = ctx.actions.declare_output("ghdl_stderr.log") - yosys_synth_cmd = cmd_args(hidden=[in_json_file, maps]) + yosys_synth_cmd = cmd_args(hidden=[in_json_file]) yosys_synth_cmd.add(ctx.attrs._python[PythonToolchainInfo].interpreter) yosys_synth_cmd.add(yosys_py) yosys_synth_cmd.add("--output", yosys_json.as_output()) @@ -76,7 +82,7 @@ def yosys_vhdl_synth(ctx): ctx.actions.run(yosys_synth_cmd, category="yosys_run") - providers.append(DefaultInfo(default_output=yosys_json)) + providers.append(DefaultInfo(default_output=yosys_json, other_outputs=maps)) return providers From 9e38a475192584053537f5c04c5ae2c394740798 Mon Sep 17 00:00:00 2001 From: Aaron-Hartwig Date: Fri, 21 Aug 2026 15:31:27 -0500 Subject: [PATCH 5/5] bsv: restore .adoc register map output for BSV projects These four rdl targets emitted an .adoc register map under cobble but lost it in the move to buck2; their BUILD files still ask for one. Now that rdl_file accepts .adoc and _MAPS_DIR_EXTENSIONS carries it, they land in maps/ alongside the html and json. Two names could not be carried over verbatim. rdl.bzl requires the output basename to match the .rdl stem, so minibar_regs.adoc becomes minibar_controller.adoc and sidecar_qsfp_x32_controller_regs.adoc becomes qsfp_x32_controller.adoc. gimlet/sequencer is deliberately not included -- it never requested an .adoc under cobble either. All four are flat single address maps, so this is the first real exercise of the MapExporter (flatten_names = False) branch of regmap_adoc.jinja2; cosmo_seq_top is a map of maps and does not cover it. All four render clean under asciidoctor --failure-level=WARN with no duplicate section names, and minibar's two nested ignition_controller addrmaps at 0x100/0x200 flatten to IGNITION_CONTROLLER0_* as expected. --- hdl/ip/bsv/ignition/BUCK | 1 + hdl/projects/minibar/BUCK | 1 + hdl/projects/sidecar/mainboard/BUCK | 1 + hdl/projects/sidecar/qsfp_x32/BUCK | 1 + 4 files changed, 4 insertions(+) diff --git a/hdl/ip/bsv/ignition/BUCK b/hdl/ip/bsv/ignition/BUCK index 3c071f03..ef6c1865 100644 --- a/hdl/ip/bsv/ignition/BUCK +++ b/hdl/ip/bsv/ignition/BUCK @@ -115,6 +115,7 @@ rdl_file( outputs = [ "IgnitionControllerRegisters.bsv", "ignition_controller.html", + "ignition_controller.adoc", "ignition_controller.json", ], src = "ignition_controller.rdl", diff --git a/hdl/projects/minibar/BUCK b/hdl/projects/minibar/BUCK index c4f1d690..b131abaf 100644 --- a/hdl/projects/minibar/BUCK +++ b/hdl/projects/minibar/BUCK @@ -11,6 +11,7 @@ rdl_file( outputs = [ "MinibarRegsPkg.bsv", "minibar_controller.html", + "minibar_controller.adoc", "minibar_controller.json", ], ) diff --git a/hdl/projects/sidecar/mainboard/BUCK b/hdl/projects/sidecar/mainboard/BUCK index 9dde2a8f..4c5c5fc6 100644 --- a/hdl/projects/sidecar/mainboard/BUCK +++ b/hdl/projects/sidecar/mainboard/BUCK @@ -12,6 +12,7 @@ rdl_file( outputs = [ "SidecarMainboardControllerReg.bsv", "sidecar_mainboard_controller.html", + "sidecar_mainboard_controller.adoc", "sidecar_mainboard_controller.json", ], ) diff --git a/hdl/projects/sidecar/qsfp_x32/BUCK b/hdl/projects/sidecar/qsfp_x32/BUCK index 45743601..481cebcd 100644 --- a/hdl/projects/sidecar/qsfp_x32/BUCK +++ b/hdl/projects/sidecar/qsfp_x32/BUCK @@ -24,6 +24,7 @@ rdl_file( outputs = [ "QsfpX32ControllerRegsPkg.bsv", "qsfp_x32_controller.html", + "qsfp_x32_controller.adoc", "qsfp_x32_controller.json", ], )