summaryrefslogtreecommitdiff
path: root/lib9p/idl
diff options
context:
space:
mode:
Diffstat (limited to 'lib9p/idl')
-rw-r--r--lib9p/idl/0000-README.md73
-rw-r--r--lib9p/idl/0000-TODO.md11
-rw-r--r--lib9p/idl/1992-9P0.9p.wip166
-rw-r--r--lib9p/idl/1995-9P1.9p.wip141
-rw-r--r--lib9p/idl/1996-Styx.9p.wip66
-rw-r--r--lib9p/idl/2002-9P2000.9p122
-rw-r--r--lib9p/idl/2003-9P2000.p9p.9p49
-rw-r--r--lib9p/idl/2005-9P2000.u.9p35
-rw-r--r--lib9p/idl/2010-9P2000.L.9p230
-rw-r--r--lib9p/idl/2010-9P2000.L.9p.wip56
-rw-r--r--lib9p/idl/2012-9P2000.e.9p8
-rw-r--r--lib9p/idl/__init__.py878
12 files changed, 1599 insertions, 236 deletions
diff --git a/lib9p/idl/0000-README.md b/lib9p/idl/0000-README.md
index cec27e2..84cf865 100644
--- a/lib9p/idl/0000-README.md
+++ b/lib9p/idl/0000-README.md
@@ -1,7 +1,7 @@
<!--
- 0000-README.md - Overview of 9P protocol definitions
+ lib9p/idl/0000-README.md - Overview of 9P protocol definitions
- Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
+ Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
@@ -17,35 +17,82 @@ client->server requests, and R-messages are server->client responses
type of a message is represented by a u8 ID; T-messages are even and
R-messages are odd.
-Messages are made up of the primitives; unsigned little-endian
-integers, identified with the following single-character mnemonics:
+9P messages are exchanged over a reliable bidirectional in-order octet
+stream. Messages are made up of the primitives; unsigned
+little-endian integers, identified with the following single-character
+mnemonics:
- 1 = u8
- 2 = u16le
- 4 = u32le
- 8 = u16le
-Out of these primitives, we can make other numeric types,
+Out of these primitives, we can make more complex types:
+
+## User-defined types
+
+### Numeric types
num NUMNAME = PRIMITIVE_TYPE
+ "NAME=VAL"...
+
+Besides just being an alias for a primitive type, a numeric type may
+define 0 or more named constants of that type, each wrapped in
+"quotes".
+
+### Bitfields
+
+ bitfield BFNAME = PRIMITIVE_TYPE
+ "bit NBIT=NAME"...
+ "bit NBIT=reserved(NAME)"...
+ "bit NBIT=num(NUMNAME)"...
+ "alias NAME=VAL"...
+ "mask NAME=VAL"...
+ "num(NUMNAME) NAME=VAL"...
-bitfields,
+The same NBIT may not be defined multiple times. The same NAME may
+not be defined multiple times.
- bitfield BFNAME = PRIMITIVE_TYPE "NBIT=NAME... ALIAS=VAL..."
+ - A `reserved(...)` bit indicates that the bit is named but is not
+ allowed to be used.
+ - `num(...)` bits embed a numeric/enumerated field within a set of
+ bits. Once several bits have been allocated to a numeric field
+ with `bit NBIT=num(NUMNAME)`, constant values for that field may be
+ declared with `num(NUMNAME) NAME=VAL`. For each numeric field, a
+ `mask NUMNAME=BITMASK` is automatically declared.
+ - A `mask` defines a bitmask that selects several bits.
+ - An `alias` defines a convenience alias for a bit or set of bits.
-structures,
+### Structures
- struct STRUCTNAME = "FILENAME[FIELDTYPE]..."
+ struct STRUCTNAME = "FIELDNAME[FIELDTYPE]..."
-and messages (which are a special-case of structures).
+Or a special-case for structs that are messages; `msg` has the same
+syntax as `struct`, but has restrictions on the STRUCTNAME and the
+first 3 fields must all be declared in the same way:
msg Tname = "size[4,val=end-&size] typ[1,val=TYP] tag[tag] REST..."
Struct fields that have numeric types (either primitives or `num`
types) can add to their type `,val=` and/or `,max=` to specify what
the exact value must be and/or what the maximum (inclusive) value is.
+A field that is repeated a variable number of times be wrapped in
+parenthesis and prefixed with the fieldname containing that count:
+`OTHERFIELDNAME*(FIELDNAME[FIELDTYPE])`.
`,val=` and `,max` take a string of `+`/`-` tokens and values; a value
-can either be a decimal numeric constant (eg: `107`), the `&fieldname`
-to refer to the offset of a field name in that struct, or the special
-value `end` to refer to the offset of the end of the struct.
+can be
+ - a decimal numeric constant (eg: `107`),
+ - `&fieldname` to refer to the offset of a field name in that struct,
+ - the special value `end` to refer to the offset of the end of the
+ struct,
+ - the special value `u{8,16,32,64}_max` to refer to the constant
+ value `(1<<{n})-1`, or
+ - the special value `s{8,16,32,64}_max` to refer to the constant value
+ `(1<<({n}-1))-1`.
+
+## Parser
+
+A parser for this syntax is given in `__init__.py`. However,
+`__init__.py` places the somewhat arbitrary undocumented restrictions
+on fields referenced as the count of a repeated field.
diff --git a/lib9p/idl/0000-TODO.md b/lib9p/idl/0000-TODO.md
new file mode 100644
index 0000000..e52902f
--- /dev/null
+++ b/lib9p/idl/0000-TODO.md
@@ -0,0 +1,11 @@
+<!--
+ lib9p/idl/0000-TODO.md - Changes I intend to make to idl/__init__.py
+ and proto.gen
+
+ Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+ SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+- Decide how to handle duplicate type names from different versions
+- Decide how to handle duplicate `enum lib9p_msg_type` names and
+ values
diff --git a/lib9p/idl/1992-9P0.9p.wip b/lib9p/idl/1992-9P0.9p.wip
index 4278fa3..a434ba2 100644
--- a/lib9p/idl/1992-9P0.9p.wip
+++ b/lib9p/idl/1992-9P0.9p.wip
@@ -1,9 +1,22 @@
-# 1992-9P0.9p - Definitions of 9P0 (Plan 9 1st ed) messages
+# lib9p/idl/1992-9P0.9p - Definitions of 9P0 (Plan 9 1st ed) messages
#
-# Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
-# https://man.cat-v.org/plan_9_1st_ed/5/
+# The original 9P protocol ("9P0"), from Plan 9 1st edition.
+#
+# Documentation:
+# - https://github.com/plan9foundation/plan9/tree/1e-1993-01-03/sys/man/5
+# - https://github.com/plan9foundation/plan9/tree/1e-1993-01-03/sys/man/6/auth
+# - https://man.cat-v.org/plan_9_1st_ed/5/
+# - https://man.cat-v.org/plan_9_1st_ed/6/auth
+#
+# Implementation references:
+# - https://github.com/plan9foundation/plan9/blob/1e-1993-01-03/sys/include/fcall.h (MAXFDATA)
+# - https://github.com/plan9foundation/plan9/blob/1e-1993-01-03/sys/include/libc.h (`ch` bits)
+# - https://github.com/plan9foundation/plan9/blob/1e-1993-01-03/sys/src/fs/port/fcall.c (`stat`)
+# - https://github.com/plan9foundation/plan9/blob/1e-1993-01-03/sys/src/fs/port/fs.c (`offset:max`)
+# - https://github.com/plan9foundation/plan9/blob/1e-1993-01-03/sys/src/fs/port/portdata.h (MAXDAT)
version "9P0"
# tag - identify a request/response pair
@@ -15,39 +28,114 @@ num fid = 2
# uni"Q"ue "ID"entification
struct qid = "path[4] version[4]"
-# a nul-padded string
-struct name = 28*(txt[1])
-
-msg Tnop = "typ[1,val=TODO] tag[tag,val=0xFFFF]"
-msg Rnop = "typ[1,val=TODO] tag[tag,val=0xFFFF]"
-msg Tsession = "typ[1,val=TODO] tag[tag,val=0xFFFF]"
-msg Rsession = "typ[1,val=TODO] tag[tag,val=0xFFFF]"
-msg Rerror = "typ[1,val=TODO] tag[tag] ename[64]"
-msg Tflush = "typ[1,val=TODO] tag[tag] oldtag[tag]"
-msg Rflush = "typ[1,val=TODO] tag[tag]"
-msg Tauth = "typ[1,val=TODO] tag[tag] fid[fid] uid[28] chal[36]"
-msg Rauth = "typ[1,val=TODO] tag[tag] fid[fid] chal[30]"
-msg Tattach = "typ[1,val=TODO] tag[tag] fid[fid] uid[28] aname[28] auth[28]"
-msg Rattach = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Tclone = "typ[1,val=TODO] tag[tag] fid[fid] newfid[fid]"
-msg Rclone = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Tclwalk = "typ[1,val=TODO] tag[tag] fid[fid] newfid[fid] name[28]"
-msg Rclwalk = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Twalk = "typ[1,val=TODO] tag[tag] fid[fid] name[28]"
-msg Rwalk = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Topen = "typ[1,val=TODO] tag[tag] fid[fid] mode[1]"
-msg Ropen = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Tcreate = "typ[1,val=TODO] tag[tag] fid[fid] name[28] perm[4] mode[1]"
-msg Rcreate = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Tread = "typ[1,val=TODO] tag[tag] fid[fid] offset[8] count[2,max=8192]"
-msg Rread = "typ[1,val=TODO] tag[tag] fid[fid] count[2,max=8192] pad[1] count*(data[1])"
-msg Twrite = "typ[1,val=TODO] tag[tag] fid[fid] offset[8] count[2,max=8192] pad[1] count*(data[1])"
-msg Rwrite = "typ[1,val=TODO] tag[tag] fid[fid] count[2,max=8192]"
-msg Tclunk = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Rclunk = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Tremove = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Rremove = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Tstat = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Rstat = "typ[1,val=TODO] tag[tag] fid[fid] stat[116]"
-msg Twstat = "typ[1,val=TODO] tag[tag] fid[fid] stat[116]"
-msg Rwstat = "typ[1,val=TODO] tag[tag] fid[fid]"
+# a nul-terminated+padded string
+struct name = "28*(txt[1])"
+
+# a nul-terminated+padded string
+struct errstr = "64*(txt[1])"
+
+# "O"pen flags (flags to pass to Topen and Tcreate)
+# Unused bits are *ignored*.
+bitfield o = 1
+ "bit 0=num(MODE)" # low bit of the 2-bit READ/WRITE/RDWR/EXEC enum
+ "bit 1=num(MODE)" # high bit of the 2-bit READ/WRITE/RDWR/EXEC enum
+ #"bit 2=unused"
+ #"bit 3=unused"
+ "bit 4=TRUNC"
+ "bit 5=reserved(CEXEC)" # close-on-exec
+ "bit 6=RCLOSE" # remove-on-close
+ #"bit 7=unused"
+
+ "num(MODE) READ = 0" # make available for this FID: Tread()
+ "num(MODE) WRITE = 1" # make available for this FID: Twrite()
+ "num(MODE) RDWR = 2" # make available for this FID: Tread() and Twrite()
+ "num(MODE) EXEC = 3" # make available for this FID: Tread()
+
+ "mask FLAG = 0b11111100"
+
+# "CH"annel flags - file permissions and attributes (a "channel" is
+# what a file handle is called inside of the Plan 9 kernel).
+bitfield ch = 4
+ "bit 31=DIR"
+ "bit 30=APPEND"
+ "bit 29=EXCL"
+ #...
+ "bit 8=OWNER_R"
+ "bit 7=OWNER_W"
+ "bit 6=OWNER_X"
+ "bit 5=GROUP_R"
+ "bit 4=GROUP_W"
+ "bit 3=GROUP_X"
+ "bit 2=OTHER_R"
+ "bit 1=OTHER_W"
+ "bit 0=OTHER_X"
+
+ "mask PERM=0777" # {OWNER,GROUP,OTHER}_{R,W,X}
+
+struct stat = "file_name[name]"
+ "file_owner[name]"
+ "file_group[name]"
+ "file_qid[qid]"
+ "file_mode[ch]"
+ "file_atime[4]"
+ "file_mtime[4]"
+ "file_size[8]"
+ "kern_type[2]"
+ "kern_dev[2]"
+
+# Authentication uses symetric-key encryption, using a per-client
+# secret-key. The encryption scheme is beyond the scope of this
+# document.
+struct auth_ticket = "15*(dat[1])"
+struct encrypted_auth_challenge = "36*(ciphertext[1])"
+struct cleartext_auth_challenge = "magic[1,val=1] 7*(client_challenge[1]) server[name]"
+struct encrypted_auth_response = "30*(ciphertext[1])"
+struct cleartext_auth_response = "magic[1,val=4] 7*(client_challenge[1]) ticket[auth_ticket]"
+
+# A 9P0 session goes:
+#
+# [nop()]
+# session()
+# [auth_tok=auth()]
+# attach([auth_tok])
+# ...
+
+msg Tmux = "typ[1,val=48] mux[2]" # Undocumented, but implemented by mux(3) / libmux.a
+#msg Rmux = "typ[1,val=49] illegal"
+msg Tnop = "typ[1,val=50] tag[tag,val=0xFFFF]"
+msg Rnop = "typ[1,val=51] tag[tag,val=0xFFFF]"
+msg Tsession = "typ[1,val=52] tag[tag,val=0xFFFF]"
+msg Rsession = "typ[1,val=53] tag[tag,val=0xFFFF]"
+#msg Terror = "typ[1,val=54] illegal"
+msg Rerror = "typ[1,val=55] tag[tag] ename[errstr]"
+msg Tflush = "typ[1,val=56] tag[tag] oldtag[tag]"
+msg Rflush = "typ[1,val=57] tag[tag]"
+msg Tattach = "typ[1,val=58] tag[tag] fid[fid] uid[name] aname[name] auth[auth_ticket] 13*(pad[1])" # Pad to allow auth_tickets up to 28 bytes.
+msg Rattach = "typ[1,val=59] tag[tag] fid[fid] qid[qid]"
+msg Tclone = "typ[1,val=60] tag[tag] fid[fid] newfid[fid]"
+msg Rclone = "typ[1,val=61] tag[tag] fid[fid]"
+msg Twalk = "typ[1,val=62] tag[tag] fid[fid] name[name]"
+msg Rwalk = "typ[1,val=63] tag[tag] fid[fid] qid[qid]"
+msg Topen = "typ[1,val=64] tag[tag] fid[fid] mode[o]"
+msg Ropen = "typ[1,val=65] tag[tag] fid[fid] qid[qid]"
+msg Tcreate = "typ[1,val=66] tag[tag] fid[fid] name[name] perm[ch] mode[o]"
+msg Rcreate = "typ[1,val=67] tag[tag] fid[fid] qid[qid]"
+# For `count:max`, see 1e/2e/3e `sys/include/fcall.h:MAXFDATA` or 1e/2e `sys/src/fs/port/portdata.h:MAXDAT`.
+# For read `offset:max`, see 1e/2e/3e `sys/src/fs/port/fs.c:f_read()` or 3e `sys/src/lib9p/srv.c:srv():case Tread`.
+# For write `offset:max`, see 1e/2e/3e `sys/src/fs/port/fs.c:f_write()`.
+msg Tread = "typ[1,val=68] tag[tag] fid[fid] offset[8,max=s64_max] count[2,max=8192]"
+msg Rread = "typ[1,val=69] tag[tag] fid[fid] count[2,max=8192] pad[1] count*(data[1])"
+msg Twrite = "typ[1,val=70] tag[tag] fid[fid] offset[8,max=s64_max] count[2,max=8192] pad[1] count*(data[1])"
+msg Rwrite = "typ[1,val=71] tag[tag] fid[fid] count[2,max=8192]"
+msg Tclunk = "typ[1,val=72] tag[tag] fid[fid]"
+msg Rclunk = "typ[1,val=73] tag[tag] fid[fid]"
+msg Tremove = "typ[1,val=74] tag[tag] fid[fid]"
+msg Rremove = "typ[1,val=75] tag[tag] fid[fid]"
+msg Tstat = "typ[1,val=76] tag[tag] fid[fid]"
+msg Rstat = "typ[1,val=77] tag[tag] fid[fid] stat[stat]"
+msg Twstat = "typ[1,val=78] tag[tag] fid[fid] stat[stat]"
+msg Rwstat = "typ[1,val=79] tag[tag] fid[fid]"
+msg Tclwalk = "typ[1,val=80] tag[tag] fid[fid] newfid[fid] name[name]"
+msg Rclwalk = "typ[1,val=81] tag[tag] fid[fid] qid[qid]"
+msg Tauth = "typ[1,val=82] tag[tag] fid[fid] uid[name] chal[encrypted_auth_challenge]" # chal is an encrypted
+msg Rauth = "typ[1,val=83] tag[tag] fid[fid] chal[encrypted_auth_response]"
diff --git a/lib9p/idl/1995-9P1.9p.wip b/lib9p/idl/1995-9P1.9p.wip
index 55814d4..660e24a 100644
--- a/lib9p/idl/1995-9P1.9p.wip
+++ b/lib9p/idl/1995-9P1.9p.wip
@@ -1,52 +1,107 @@
-# 1995-9P1.9p - Definitions of 9P1 (Plan 9 2nd ed and 3rd ed) messages
+# lib9p/idl/1995-9P1.9p - Definitions of 9P1 (Plan 9 2nd ed and 3rd ed) messages
#
-# Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
-# https://man.cat-v.org/plan_9_2nd_ed/5/
-# https://man.cat-v.org/plan_9_3rd_ed/5/
+# Plan 9 2nd and 3rd edition used a version of 9P lightly revised from
+# the 1st edition version, re-thinking authentication.
+#
+# 2nd edition documentation:
+# - https://github.com/plan9foundation/plan9/tree/2e-1995-04-05/sys/man/5
+# - https://github.com/plan9foundation/plan9/tree/2e-1995-04-05/sys/man/6/auth
+# - https://man.cat-v.org/plan_9_2nd_ed/5/
+# - https://man.cat-v.org/plan_9_2nd_ed/6/auth
+#
+# 2nd edition implementation references:
+# - https://github.com/plan9foundation/plan9/blob/2e-1995-04-05/sys/include/fcall.h (MAXFDATA)
+# - https://github.com/plan9foundation/plan9/blob/2e-1995-04-05/sys/include/libc.h (`ch` bits)
+# - https://github.com/plan9foundation/plan9/blob/2e-1995-04-05/sys/include/auth.h (auth matic)
+# - https://github.com/plan9foundation/plan9/blob/2e-1995-04-05/sys/src/fs/port/fcall.c (`stat`)
+# - https://github.com/plan9foundation/plan9/blob/2e-1995-04-05/sys/src/fs/port/fs.c (`offset:max`)
+# - https://github.com/plan9foundation/plan9/blob/2e-1995-04-05/sys/src/fs/port/portdata.h (`MAXDAT`)
+# - https://github.com/plan9foundation/plan9/blob/2e-1995-04-05/sys/src/libauth/convM2T.c (`auth_ticket`)
+#
+# 3rd edition documentation:
+# - https://github.com/plan9foundation/plan9/tree/3e-2001-03-28/sys/man/5
+# - https://github.com/plan9foundation/plan9/tree/3e-2001-03-28/sys/man/6/auth
+# - https://man.cat-v.org/plan_9_3rd_ed/5/
+# - https://man.cat-v.org/plan_9_3rd_ed/6/auth
+#
+# 3rd edition implementation references:
+# - https://github.com/plan9foundation/plan9/blob/3e-2001-03-28/sys/include/fcall.h (MAXFDATA)
+# - https://github.com/plan9foundation/plan9/blob/3e-2001-03-28/sys/include/libc.h (`ch` bits)
+# - https://github.com/plan9foundation/plan9/blob/3e-2001-03-28/sys/include/auth.h (auth magic)
+# - https://github.com/plan9foundation/plan9/blob/3e-2001-03-28/sys/src/fs/port/fcall.c (`stat`)
+# - https://github.com/plan9foundation/plan9/blob/3e-2001-03-28/sys/src/fs/port/fs.c (`offset:max`)
+# - https://github.com/plan9foundation/plan9/blob/3e-2001-03-28/sys/src/lib9p/srv.c (read `offset:max`)
+# - https://github.com/plan9foundation/plan9/blob/3e-2001-03-28/sys/src/libauth/convM2T.c (`auth_ticket`)
version "9P1"
-# tag - identify a request/response pair
-num tag = 2
+from ./1992-9P0.9p import tag, fid, qid, name, errstr, o, ch, stat
-# file identifier - like a UNIX file-descriptor
-num fid = 2
+# CHMOUNT is undocumented (and is explicitly excluded from the 9P2000
+# draft RFC). As I understand it, CHMOUNT indicates that the file is
+# mounted by the kernel as a 9P transport; that the kernel has a lock
+# on doing I/O on it, so userspace can't do I/O on it.
+bitfield ch += "bit 28=_PLAN9_MOUNT"
-# uni"Q"ue "ID"entification
-struct qid = "path[4] version[4]"
+# Authentication uses DES encryption. The client obtains a ticket and
+# a nonce-key from a separate auth-server; how it does this is beyond
+# the scope of this document.
+struct random = "8*(dat[1])"
+struct domain_name = "48*(txt[1])"
+struct des_key = "7*(dat[1])"
+struct encrypted_ticket = "72*(ciphertext[1])" # encrypted by auth-server with server-key
+struct cleartext_ticket = "magic[1,val=64] server_chal[random] client_uid[name] server_uid[name] nonce_key[des_key]"
+struct encrypted_authenticator_challenge = "13*(ciphertext[1])" # encrypted by client with nonce-key obtained from auth-server
+struct cleartext_authenticator_challenge = "magic[1,val=66] server_chal[random] replay_count[4]"
+struct encrypted_authenticator_response = "13*(ciphertext[1])" # encrypted by server with nonce-key obtained from ticket
+struct cleartext_authenticator_response = "magic[1,val=67] client_chal[random] replay_count[4]"
-# a nul-padded string
-struct name = 28*(txt[1])
+# A 9P0 session goes:
+#
+# [nop()]
+# auth_tok=[session()]
+# attach(auth_tok)
+# ...
-msg Tnop = "typ[1,val=TODO] tag[tag,val=0xFFFF]"
-msg Rnop = "typ[1,val=TODO] tag[tag,val=0xFFFF]"
-msg Tsession = "typ[1,val=TODO] tag[tag,val=0xFFFF] chal[8]"
-msg Rsession = "typ[1,val=TODO] tag[tag,val=0xFFFF] chal[8] authid[28] authdom[48]"
-msg Rerror = "typ[1,val=TODO] tag[tag] ename[64]"
-msg Tflush = "typ[1,val=TODO] tag[tag] oldtag[tag]"
-msg Rflush = "typ[1,val=TODO] tag[tag]"
-msg Tattach = "typ[1,val=TODO] tag[tag] fid[fid] uid[28] aname[28] ticket[72] auth[13]"
-msg Rattach = "typ[1,val=TODO] tag[tag] fid[fid] qid[8] rauth[13]"
-msg Tclone = "typ[1,val=TODO] tag[tag] fid[fid] newfid[fid]"
-msg Rclone = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Tclwalk = "typ[1,val=TODO] tag[tag] fid[fid] newfid[fid] name[28]"
-msg Rclwalk = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Twalk = "typ[1,val=TODO] tag[tag] fid[fid] name[28]"
-msg Rwalk = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Topen = "typ[1,val=TODO] tag[tag] fid[fid] mode[1]"
-msg Ropen = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Tcreate = "typ[1,val=TODO] tag[tag] fid[fid] name[28] perm[4] mode[1]"
-msg Rcreate = "typ[1,val=TODO] tag[tag] fid[fid] qid[8]"
-msg Tread = "typ[1,val=TODO] tag[tag] fid[fid] offset[8] count[2,max=8192]"
-msg Rread = "typ[1,val=TODO] tag[tag] fid[fid] count[2,max=8192] pad[1] count*(data[1])"
-msg Twrite = "typ[1,val=TODO] tag[tag] fid[fid] offset[8] count[2,max=8192] pad[1] count*(data[1])"
-msg Rwrite = "typ[1,val=TODO] tag[tag] fid[fid] count[2,max=8192]"
-msg Tclunk = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Rclunk = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Tremove = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Rremove = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Tstat = "typ[1,val=TODO] tag[tag] fid[fid]"
-msg Rstat = "typ[1,val=TODO] tag[tag] fid[fid] stat[116]"
-msg Twstat = "typ[1,val=TODO] tag[tag] fid[fid] stat[116]"
-msg Rwstat = "typ[1,val=TODO] tag[tag] fid[fid]"
+#from ./1992-9P0.9p import Tmux # typ=48 ; removed
+#from ./1992-9P0.9p import Rmux # typ=49 ; removed
+from ./1992-9P0.9p import Tnop # typ=50
+from ./1992-9P0.9p import Rnop # typ=51
+#from ./1992-9P0.9p import Tsession # typ=52 ; revised, now has typ=84
+#from ./1992-9P0.9p import Rsession # typ=53 ; revised, now has typ=85
+#from ./1992-9P0.9p import Terror # typ=54 ; never existed
+from ./1992-9P0.9p import Rerror # typ=55
+from ./1992-9P0.9p import Tflush # typ=56
+from ./1992-9P0.9p import Rflush # typ=57
+#from ./1992-9P0.9p import Tattach # typ=58 ; revised, now has typ=86
+#from ./1992-9P0.9p import Rattach # typ=59 ; revised, now has typ=87
+from ./1992-9P0.9p import Tclone # typ=60
+from ./1992-9P0.9p import Rclone # typ=61
+from ./1992-9P0.9p import Twalk # typ=62
+from ./1992-9P0.9p import Rwalk # typ=63
+from ./1992-9P0.9p import Topen # typ=64
+from ./1992-9P0.9p import Ropen # typ=65
+from ./1992-9P0.9p import Tcreate # typ=66
+from ./1992-9P0.9p import Rcreate # typ=67
+from ./1992-9P0.9p import Tread # typ=68
+from ./1992-9P0.9p import Rread # typ=69
+from ./1992-9P0.9p import Twrite # typ=70
+from ./1992-9P0.9p import Rwrite # typ=71
+from ./1992-9P0.9p import Tclunk # typ=72
+from ./1992-9P0.9p import Rclunk # typ=73
+from ./1992-9P0.9p import Tremove # typ=74
+from ./1992-9P0.9p import Rremove # typ=75
+from ./1992-9P0.9p import Tstat # typ=76
+from ./1992-9P0.9p import Rstat # typ=77
+from ./1992-9P0.9p import Twstat # typ=78
+from ./1992-9P0.9p import Rwstat # typ=79
+from ./1992-9P0.9p import Tclwalk # typ=80
+from ./1992-9P0.9p import Rclwalk # typ=81
+#from ./1992-9P0.9p import Tauth # typ=82 ; merged into Tsession
+#from ./1992-9P0.9p import Rauth # typ=83 ; merged into Rsession
+msg Tsession = "typ[1,val=84] tag[tag,val=0xFFFF] chal[random]"
+msg Rsession = "typ[1,val=85] tag[tag,val=0xFFFF] chal[random] server_name[name] server_domain[domain_name]"
+msg Tattach = "typ[1,val=86] tag[tag] fid[fid] uid[name] aname[name] ticket[encrypted_ticket] auth[encrypted_authenticator_challenge]"
+msg Rattach = "typ[1,val=87] tag[tag] fid[fid] qid[qid] rauth[encrypted_authenticator_response]"
diff --git a/lib9p/idl/1996-Styx.9p.wip b/lib9p/idl/1996-Styx.9p.wip
index 2feb24f..3cb3774 100644
--- a/lib9p/idl/1996-Styx.9p.wip
+++ b/lib9p/idl/1996-Styx.9p.wip
@@ -1,15 +1,59 @@
-# 1996-Styx.9p - Definitions of Styx messages
+# lib9p/idl/1996-Styx.9p - Definitions of Styx messages
#
-# Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
# Styx was a variant of the 9P protocol used by the Inferno operating
-# system. Message framing looks like 9P1 (1995), but semantics look
-# more like 9P2000 (2002). I am not sure whether there are Styx
-# protocol differences between Inferno 1e, 2e, or 3e (4e adopted
-# 9P2000).
-#
-# - 1996 beta
-# - 1997 1.0
-# - 1999 2nd ed
-# - 2001 3rd ed
+# system (before it switched to 9P2000 in 4th edition). It looks
+# exactly like 9P1 but with different message-type numbers and without
+# `clwalk` or `session`, and no authentication in `attach`.
+#
+# There do not appear to be Styx protocol differences between Inferno
+# 1e, 2e, or 3e.
+#
+# - 1996 beta https://github.com/inferno-os/inferno-1e0/blob/main/ see `man/html/proto*.htm`, `include/styx.h`, and `Linux/386/include/lib9.h`
+# - 1997 1e https://github.com/inferno-os/inferno-1e1/blob/master/ see `man/html/mpgs{113..124}.htm`, `include/styx.h`, `os/port/lib.h`, and `os/fs/fs.c`
+# - 1999 2e https://github.com/inferno-os/inferno-2e/blob/master/ see `include/styx.h`, `os/port/lib.h`, and `os/kfs/fs.c` (no public manpages)
+# - 2001 3e https://github.com/inferno-os/inferno-3e/blob/master/ see `include/man/5/`, `include/styx.h`, `os/port/lib.h`, and `os/kfs/fs.c`
+version "Styx"
+
+from ./1992-9P1.9p import tag, fid, qid, name, errstr, o, ch, stat
+
+# A Styx session goes:
+#
+# [nop()]
+# attach()
+# ...
+
+msg Tnop = "typ[1,val=0] tag[tag,val=0xFFFF]"
+msg Rnop = "typ[1,val=1] tag[tag,val=0xFFFF]"
+#msg Terror = "typ[1,val=2] illegal"
+msg Rerror = "typ[1,val=3] tag[tag] ename[errstr]"
+msg Tflush = "typ[1,val=4] tag[tag] oldtag[tag]"
+msg Rflush = "typ[1,val=5] tag[tag]"
+msg Tclone = "typ[1,val=6] tag[tag] fid[fid] newfid[fid]"
+msg Rclone = "typ[1,val=7] tag[tag] fid[fid]"
+msg Twalk = "typ[1,val=8] tag[tag] fid[fid] name[name]"
+msg Rwalk = "typ[1,val=9] tag[tag] fid[fid] qid[qid]"
+msg Topen = "typ[1,val=10] tag[tag] fid[fid] mode[o]"
+msg Ropen = "typ[1,val=11] tag[tag] fid[fid] qid[qid]"
+msg Tcreate = "typ[1,val=12] tag[tag] fid[fid] name[name] perm[ch] mode[o]"
+msg Rcreate = "typ[1,val=13] tag[tag] fid[fid] qid[qid]"
+# For `offset:max`, see `fs.c` `f_read()` and `f_write()`.
+# For `count:max`, see `styx.h:MAXFDATA`.
+msg Tread = "typ[1,val=14] tag[tag] fid[fid] offset[8,max=s64_max] count[2,max=8192]"
+msg Rread = "typ[1,val=15] tag[tag] fid[fid] count[2,max=8192] pad[1] count*(data[1])"
+msg Twrite = "typ[1,val=16] tag[tag] fid[fid] offset[8,max=s64_max] count[2,max=8192] pad[1] count*(data[1])"
+msg Rwrite = "typ[1,val=17] tag[tag] fid[fid] count[2,max=8192]"
+msg Tclunk = "typ[1,val=18] tag[tag] fid[fid]"
+msg Rclunk = "typ[1,val=19] tag[tag] fid[fid]"
+msg Tremove = "typ[1,val=20] tag[tag] fid[fid]"
+msg Rremove = "typ[1,val=21] tag[tag] fid[fid]"
+msg Tstat = "typ[1,val=22] tag[tag] fid[fid]"
+msg Rstat = "typ[1,val=23] tag[tag] fid[fid] stat[stat]"
+msg Twstat = "typ[1,val=24] tag[tag] fid[fid] stat[stat]"
+msg Rwstat = "typ[1,val=25] tag[tag] fid[fid]"
+#msg Tsession = "typ[1,val=26]" # The 1e kernel used Tsession in structs internally, but never transmitted it.
+#msg Rsession = "typ[1,val=27]" # Implied by Tsession.
+msg Tattach = "typ[1,val=28] tag[tag] fid[fid] uid[name] aname[name]"
+msg Rattach = "typ[1,val=29] tag[tag] fid[fid] qid[qid]"
diff --git a/lib9p/idl/2002-9P2000.9p b/lib9p/idl/2002-9P2000.9p
index 47d402a..2b51612 100644
--- a/lib9p/idl/2002-9P2000.9p
+++ b/lib9p/idl/2002-9P2000.9p
@@ -1,6 +1,6 @@
-# 2002-9P2000.9p - Definitions of 9P2000 messages
+# lib9p/idl/2002-9P2000.9p - Definitions of 9P2000 messages
#
-# Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
# "9P2000" base protocol
@@ -9,74 +9,77 @@
#
# But due to incompleteness of the draft RFC, the Plan 9 manual
# section-5 and the Plan 9 headers (particularly fcall.h) are often
-# better references.
+# better references. The s{32,64}_max limitations are not documented
+# in the draft RFC or the manual pages, but have been enforced by
+# lib9p/srv.c in every release of Plan 9 4e.
#
# https://github.com/plan9foundation/plan9/tree/main/sys/man/5
# https://man.cat-v.org/plan_9/5/
# https://github.com/plan9foundation/plan9/blob/main/sys/include/fcall.h
+# https://github.com/plan9foundation/plan9/blob/main/sys/include/libc.h
+# https://github.com/plan9foundation/plan9/blob/main/sys/src/lib9p/srv.c
version "9P2000"
# tag - identify a request/response pair
num tag = 2
+ "NOTAG = u16_max"
# file identifier - like a UNIX file-descriptor
num fid = 4
-
-# data - u32le `n`, then `n` bytes of data
-struct d = "len[4] len*(dat[1])"
+ "NOFID = u32_max"
# string - u16le `n`, then `n` bytes of UTF-8, without any nul-bytes
struct s = "len[2] len*(utf8[1])"
-# "d"? mode - file permissions and attributes
+# "D"ir-entry "M"ode - file permissions and attributes
bitfield dm = 4
- "31=DIR"
- "30=APPEND"
- "29=EXCL"
- # DMMOUNT has been around in Plan 9 forever, but is
- # undocumented, and is explicitly excluded from the 9P2000
- # draft RFC. As I understand it, DMMOUNT indicates that the
- # file is mounted by the kernel as a 9P transport; that the
- # kernel has a lock on doing I/O on it, so userspace can't do
- # I/O on it.
- "28=_PLAN9_MOUNT"
- "27=AUTH"
- "26=TMP"
+ "bit 31=DIR"
+ "bit 30=APPEND"
+ "bit 29=EXCL"
+ # DMMOUNT has been around in Plan 9 since 2e (CHMOUNT in <4e),
+ # but is undocumented, and is explicitly excluded from the
+ # 9P2000 draft RFC. As I understand it, DMMOUNT indicates
+ # that the file is mounted by the kernel as a 9P transport;
+ # that the kernel has a lock on doing I/O on it, so userspace
+ # can't do I/O on it.
+ "bit 28=_PLAN9_MOUNT"
+ "bit 27=AUTH"
+ "bit 26=TMP"
#...
- "8=OWNER_R"
- "7=OWNER_W"
- "6=OWNER_X"
- "5=GROUP_R"
- "4=GROUP_W"
- "3=GROUP_X"
- "2=OTHER_R"
- "1=OTHER_W"
- "0=OTHER_X"
+ "bit 8=OWNER_R"
+ "bit 7=OWNER_W"
+ "bit 6=OWNER_X"
+ "bit 5=GROUP_R"
+ "bit 4=GROUP_W"
+ "bit 3=GROUP_X"
+ "bit 2=OTHER_R"
+ "bit 1=OTHER_W"
+ "bit 0=OTHER_X"
- "PERM_MASK=0777" # {OWNER,GROUP,OTHER}_{R,W,X}
+ "mask PERM=0777" # {OWNER,GROUP,OTHER}_{R,W,X}
# QID Type - see `struct qid` below
bitfield qt = 1
- "7=DIR"
- "6=APPEND"
- "5=EXCL"
- "4=_PLAN9_MOUNT" # see "MOUNT" in "dm" above
- "3=AUTH"
+ "bit 7=DIR"
+ "bit 6=APPEND"
+ "bit 5=EXCL"
+ "bit 4=_PLAN9_MOUNT" # See "_PLAN9_MOUNT" in "dm" above.
+ "bit 3=AUTH"
# Fun historical fact: QTTMP was a relatively late addition to
# Plan 9, in 2003-12.
- "2=TMP"
- #"1=unused"
+ "bit 2=TMP"
+ #"bit 1=unused"
# "The name QTFILE, defined to be zero, identifies the value
# of the type for a plain file."
- "FILE=0"
+ "alias FILE=0"
# uni"Q"ue "ID"entification - "two files on the same server hierarchy
# are the same if and only if their qids are the same"
#
# - "path" is a unique uint64_t that does most of the work in the
# above statement about files being the same if their QIDs are the
-# same; " If a file is deleted and recreated with the same name in
+# same; "If a file is deleted and recreated with the same name in
# the same directory, the old and new path components of the qids
# should be different"
#
@@ -102,23 +105,30 @@ struct stat = "stat_size[2,val=end-&kern_type]"
"file_last_modified_uid[s]"
# "O"pen flags (flags to pass to Topen and Tcreate)
+# Unused bits *must* be 0.
bitfield o = 1
- "0=mode_0" # low bit of the 2-bit READ/WRITE/RDWR/EXEC enum
- "1=mode_1" # high bit of the 2-bit READ/WRITE/RDWR/EXEC enum"
- #"2=unused"
- #"3=unused"
- "4=TRUNC"
- #"5=unused"
- "6=RCLOSE" # remove-on-close
- #"7=unused"
+ "bit 0=num(MODE)" # low bit of the 2-bit READ/WRITE/RDWR/EXEC enum
+ "bit 1=num(MODE)" # high bit of the 2-bit READ/WRITE/RDWR/EXEC enum
+ #"bit 2=unused"
+ #"bit 3=unused"
+ "bit 4=TRUNC"
+ "bit 5=reserved(CEXEC)" # close-on-exec
+ "bit 6=RCLOSE" # remove-on-close
+ #"bit 7=unused"
+
+ "num(MODE) READ = 0" # make available for this FID: Tread()
+ "num(MODE) WRITE = 1" # make available for this FID: Twrite()
+ "num(MODE) RDWR = 2" # make available for this FID: Tread() and Twrite()
+ "num(MODE) EXEC = 3" # make available for this FID: Tread()
- "READ = 0" # make available for this FID: Tread()
- "WRITE = 1" # make available for this FID: Twrite()
- "RDWR = 2" # make available for this FID: Tread() and Twrite()
- "EXEC = 3" # make available for this FID: Tread()
+ "mask FLAG = 0b11111100"
- "MODE_MASK = 0b00000011"
- "FLAG_MASK = 0b11111100"
+# A 9P2000 session goes:
+#
+# version()
+# [auth_fid=auth]
+# attach([auth_fid])
+# ...
msg Tversion = "size[4,val=end-&size] typ[1,val=100] tag[tag] max_msg_size[4] version[s]"
msg Rversion = "size[4,val=end-&size] typ[1,val=101] tag[tag] max_msg_size[4] version[s]"
@@ -136,10 +146,10 @@ msg Topen = "size[4,val=end-&size] typ[1,val=112] tag[tag] fid[fid] mode[o]"
msg Ropen = "size[4,val=end-&size] typ[1,val=113] tag[tag] qid[qid] iounit[4]"
msg Tcreate = "size[4,val=end-&size] typ[1,val=114] tag[tag] fid[fid] name[s] perm[dm] mode[o]"
msg Rcreate = "size[4,val=end-&size] typ[1,val=115] tag[tag] qid[qid] iounit[4]"
-msg Tread = "size[4,val=end-&size] typ[1,val=116] tag[tag] fid[fid] offset[8] count[4]"
-msg Rread = "size[4,val=end-&size] typ[1,val=117] tag[tag] data[d]" # for directories `data` is the sequence "cnt*(entries[stat])"
-msg Twrite = "size[4,val=end-&size] typ[1,val=118] tag[tag] fid[fid] offset[8] data[d]"
-msg Rwrite = "size[4,val=end-&size] typ[1,val=119] tag[tag] count[4]"
+msg Tread = "size[4,val=end-&size] typ[1,val=116] tag[tag] fid[fid] offset[8,max=s64_max] count[4,max=s32_max]" # See 4e `sys/src/lib9p/srv.c:sread()` for `offset:max` and `count:max`.
+msg Rread = "size[4,val=end-&size] typ[1,val=117] tag[tag] count[4,max=s32_max] count*(data[1])" # `max` is inherited from Tread, for directories `data` is the sequence "cnt*(entries[stat])".
+msg Twrite = "size[4,val=end-&size] typ[1,val=118] tag[tag] fid[fid] offset[8,max=s64_max] count[4,max=s32_max] count*(data[1])" # See 4e `sys/src/lib9p/srv.c:swrite()` for `offset:max` and `count:max`.
+msg Rwrite = "size[4,val=end-&size] typ[1,val=119] tag[tag] count[4,max=s32_max]" # `max` is inherited from Twrite.
msg Tclunk = "size[4,val=end-&size] typ[1,val=120] tag[tag] fid[fid]"
msg Rclunk = "size[4,val=end-&size] typ[1,val=121] tag[tag]"
msg Tremove = "size[4,val=end-&size] typ[1,val=122] tag[tag] fid[fid]"
diff --git a/lib9p/idl/2003-9P2000.p9p.9p b/lib9p/idl/2003-9P2000.p9p.9p
new file mode 100644
index 0000000..3f6a524
--- /dev/null
+++ b/lib9p/idl/2003-9P2000.p9p.9p
@@ -0,0 +1,49 @@
+# lib9p/idl/2003-9P2000.p9p.9p - Definitions of plan9port extension messages
+#
+# Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# Plan 9 from User Space (a.k.a. plan9port)'s lib9pclient:fsopenfd(3)
+# and 9pserve(4) proxy server (which takes the place of the Plan 9
+# kernel) add a special-purpose `openfd` command to 9P2000.
+#
+# https://9fans.github.io/plan9port/man/man9/openfd.htlm
+# https://github.com/9fans/plan9port/blob/master/man/man9/openfd.9p
+# https://github.com/9fans/plan9port/commit/32f69c36e0eec1227934bbd34854bfebd88686f2
+# https://github.com/9fans/plan9port/pull/692
+
+# BUG: There is no version-string for this extension; plan9port still
+# calls it vanilla "9P2000".
+version "9P2000.p9p"
+
+from ./2002-9P2000.9p import *
+
+# On Plan 9 the usual 9P client is the kernel, not normal userspace
+# programs. The kernel multiplexes multiple userspace processes onto
+# a single 9P connection; the userspace programs make 9P calls by
+# making syscalls. plan9port emulates this by having mock syscalls
+# that make 9P client calls over an AF_UNIX socket to a local
+# `9pserve` daemon that does the multiplexing (you add an "fs" prefix;
+# e.g. you replace syscall:`open()` with lib9pclient:`fsopen()`).
+#
+# "Unfortunately", programs in plan9port must deal both with 9P files
+# and native "Unix" files; and need to turn an 9P FID into a native
+# file descriptor. To do this, the `9pserve` program and lib9pclient
+# add an extension call to 9P2000: Topenfd/Ropenfd/fsopenfd().
+#
+# An AF_UNIX socket has the ability to send a file descriptor over it
+# via an out-of-band "socket control message" ("CMSG" or "SCM").
+#
+# Topenfd asks 9pserve to create a socketpair() file descriptor that
+# 9pserve will pump to/from a FID, and then send that pipe file
+# descriptor over a control-message to the client program.
+#
+# When replying, the server sends not just an in-band Ropenfd message,
+# but also an out-of-band control-message with a socketpair() file
+# descriptor. A successful call results in the FID being clunked.
+msg Topenfd = "size[4,val=end-&size] typ[1,val=98] tag[tag] fid[fid] mode[o]"
+msg Ropenfd = "size[4,val=end-&size] typ[1,val=99] tag[tag] qid[qid] iounit[4] unixfd[4]"
+# BUG: The "unixfd" field nominally indicates the the file descriptor
+# of the pipe, but really 9pserve doesn't know which FD it will end up
+# on the client process, and lib9pclient ignores the value here and
+# overwrites it with the file descriptor indicated from the CMSG
diff --git a/lib9p/idl/2005-9P2000.u.9p b/lib9p/idl/2005-9P2000.u.9p
index 8b59efa..6c2f2dc 100644
--- a/lib9p/idl/2005-9P2000.u.9p
+++ b/lib9p/idl/2005-9P2000.u.9p
@@ -1,6 +1,6 @@
-# 2005-9P2000.u.9p - Definitions of 9P2000.u messages
+# lib9p/idl/2005-9P2000.u.9p - Definitions of 9P2000.u messages
#
-# Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
# "9P2000.u" Unix extension
@@ -10,20 +10,27 @@ version "9P2000.u"
from ./2002-9P2000.9p import *
+# numeric user ID
+num nuid = 4
+ "NONUID = u32_max"
+
+num errno = 4
+ "NOERROR = 0"
+
struct stat += "file_extension[s]"
- "file_owner_n_uid[4]"
- "file_owner_n_gid[4]"
- "file_last_modified_n_uid[4]"
+ "file_owner_n_uid[nuid]"
+ "file_owner_n_gid[nuid]"
+ "file_last_modified_n_uid[nuid]"
-msg Tauth += "n_uname[4]"
-msg Tattach += "n_uname[4]"
+msg Tauth += "n_uid[nuid]"
+msg Tattach += "n_uid[nuid]"
-msg Rerror += "errno[4]"
+msg Rerror += "errno[errno]"
-bitfield dm += "23=DEVICE"
- "21=NAMEDPIPE"
- "20=SOCKET"
- "19=SETUID"
- "18=SETGID"
+bitfield dm += "bit 23=DEVICE"
+ "bit 21=PIPE"
+ "bit 20=SOCKET"
+ "bit 19=SETUID"
+ "bit 18=SETGID"
-bitfield qt += "1=SYMLINK"
+bitfield qt += "bit 1=SYMLINK"
diff --git a/lib9p/idl/2010-9P2000.L.9p b/lib9p/idl/2010-9P2000.L.9p
new file mode 100644
index 0000000..d81a15b
--- /dev/null
+++ b/lib9p/idl/2010-9P2000.L.9p
@@ -0,0 +1,230 @@
+# lib9p/idl/2010-9P2000.L.9p - Definitions of 9P2000.L messages
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# "9P2000.L" Linux extension
+# https://github.com/chaos/diod/blob/master/protocol.md
+# https://github.com/chaos/diod/blob/master/src/libnpfs/protocol.h
+version "9P2000.L"
+
+from ./2002-9P2000.9p import tag, fid, s, qt, qid
+from ./2002-9P2000.9p import Rerror
+from ./2002-9P2000.9p import Tversion, Rversion, Tflush, Rflush, Twalk, Rwalk, Tread, Rread, Twrite, Rwrite, Tclunk, Rclunk, Tremove, Rremove
+from ./2005-9P2000.u.9p import nuid, errno, Tauth, Rauth, Tattach, Rattach
+
+#num errno += # TODO
+
+num super_magic = 4
+ # See <linux/magic.h> (linux.git include/uapi/linux/magic.h).
+ #
+ # To quote `util-linux.git:include/statfs_magic.h`:
+ # "Unfortunately, Linux kernel header file <linux/magic.h> is
+ # incomplete mess and kernel returns by statfs f_type many numbers
+ # that are nowhere specified (in API)."
+ #
+ # util-linux <statfs_magic.h> is also incomplete. As is the
+ # statfs(2) man-page.
+ #
+ # I'm working on a patchset to the kernel to get <linux/magic.h>
+ # to be complete, but in the mean-time I'm just not going to
+ # bother with putting a list here.
+ #
+ # TODO
+ "V9FS_MAGIC=0x01021997"
+
+# "L"inux "O"pen flags (flags to pass to Tlopen and Tlcreate)
+#
+# The values are not specified in in protocol.md, but are specified in
+# protocol.h (and are different than the Linux kernel's values, which
+# vary by architecture).
+bitfield lo = 4
+ "bit 0=num(MODE)" # low bit of the 2-bit RDONLY/WRONLY/RDWR/NOACCESS enum
+ "bit 1=num(MODE)" # high bit of the 2-bit RDONLY/WRONLY/RDWR/NOACCESS enum
+ #"bit 2=unused"
+ #"bit 3=unused"
+ #"bit 4=unused"
+ #"bit 5=unused"
+ "bit 6=CREATE"
+ "bit 7=EXCL"
+ "bit 8=NOCTTY"
+ "bit 9=TRUNC"
+ "bit 10=APPEND"
+ "bit 11=NONBLOCK"
+ "bit 12=DSYNC"
+ "bit 13=BSD_FASYNC"
+ "bit 14=DIRECT"
+ "bit 15=LARGEFILE"
+ "bit 16=DIRECTORY"
+ "bit 17=NOFOLLOW"
+ "bit 18=NOATIME"
+ "bit 19=CLOEXEC"
+ "bit 20=SYNC"
+
+ "num(MODE) RDONLY = 0"
+ "num(MODE) WRONLY = 1"
+ "num(MODE) RDWR = 2"
+ "num(MODE) NOACCESS = 3"
+
+ "mask FLAG = 0b111111111111111000000"
+
+# "D"irentry "T"ype
+#
+# These match the Linux kernel's values.
+num dt = 1
+ "UNKNOWN = 0"
+ "PIPE = 1"
+ "CHAR_DEV = 2"
+ "DIRECTORY = 4"
+ "BLOCK_DEV = 6" # proof it's not a bitfield
+ "REGULAR = 8"
+ "SYMLINK = 10" # proof it's not a bitfield
+ "SOCKET = 12" # proof it's not a bitfield
+ "_WHITEOUT = 14" # proof it's not a bitfield
+
+# Mode
+#
+# These match the Linux kernel's values. Why is this 32-bits wide
+# instead of just 16? Who knows?
+bitfield mode = 4
+ #...
+ "bit 15=num(FMT)" # bit of the 4-bit FMT_ enum
+ "bit 14=num(FMT)" # bit of the 4-bit FMT_ enum
+ "bit 13=num(FMT)" # bit of the 4-bit FMT_ enum
+ "bit 12=num(FMT)" # bit of the 4-bit FMT_ enum
+ #...
+ "bit 11=PERM_SETGROUP"
+ "bit 10=PERM_SETUSER"
+ "bit 9=PERM_STICKY"
+ "bit 8=PERM_OWNER_R"
+ "bit 7=PERM_OWNER_W"
+ "bit 6=PERM_OWNER_X"
+ "bit 5=PERM_GROUP_R"
+ "bit 4=PERM_GROUP_W"
+ "bit 3=PERM_GROUP_X"
+ "bit 2=PERM_OTHER_R"
+ "bit 1=PERM_OTHER_W"
+ "bit 0=PERM_OTHER_X"
+
+ "num(FMT) PIPE = dt.PIPE<<12"
+ "num(FMT) CHAR_DEV = dt.CHAR_DEV<<12"
+ "num(FMT) DIRECTORY = dt.DIRECTORY<<12"
+ "num(FMT) BLOCK_DEV = dt.BLOCK_DEV<<12"
+ "num(FMT) REGULAR = dt.REGULAR<<12"
+ "num(FMT) SYMLINK = dt.SYMLINK<<12"
+ "num(FMT) SOCKET = dt.SOCKET<<12"
+
+ "mask PERM = 07777" # PERM_*
+
+# A boolean value that is for some reason 4 bytes wide.
+num b4 = 4
+ "FALSE=0"
+ "TRUE=1"
+ # all other values are true also
+
+bitfield getattr = 8
+ "bit 0=MODE"
+ "bit 1=NLINK"
+ "bit 2=UID"
+ "bit 3=GID"
+ "bit 4=RDEV"
+ "bit 5=ATIME"
+ "bit 6=MTIME"
+ "bit 7=CTIME"
+ "bit 8=INO"
+ "bit 9=SIZE"
+ "bit 10=BLOCKS"
+
+ "bit 11=BTIME"
+ "bit 12=GEN"
+ "bit 13=DATA_VERSION"
+
+ "alias BASIC=0x000007ff" # Mask for fields up to BLOCKS
+ "alias ALL =0x00003fff" # Mask for All fields above
+
+bitfield setattr = 4
+ "bit 0=MODE"
+ "bit 1=UID"
+ "bit 2=GID"
+ "bit 3=SIZE"
+ "bit 4=ATIME"
+ "bit 5=MTIME"
+ "bit 6=CTIME"
+ "bit 7=ATIME_SET"
+ "bit 8=MTIME_SET"
+
+num lock_type = 1
+ "RDLCK=0"
+ "WRLCK=1"
+ "UNLCK=2"
+
+bitfield lock_flags = 4
+ "bit 0=BLOCK"
+ "bit 1=RECLAIM"
+
+num lock_status = 1
+ "SUCCESS=0"
+ "BLOCKED=1"
+ "ERROR=2"
+ "GRACE=3"
+
+#msg Tlerror = "size[4,val=end-&size] typ[1,val=6] tag[tag] illegal" # analogous to 106/Terror
+msg Rlerror = "size[4,val=end-&size] typ[1,val=7] tag[tag] ecode[errno]" # analogous to 107/Rerror
+msg Tstatfs = "size[4,val=end-&size] typ[1,val=8] tag[tag] fid[fid]"
+msg Rstatfs = "size[4,val=end-&size] typ[1,val=9] tag[tag]" # Description | statfs | statvfs
+ "type[super_magic]" # Type of filesystem | f_type | -
+ "bsize[4]" # Block size in bytes | f_bsize | f_bsize
+ # - # Fragment size in bytes | f_frsize (since Linux 2.6) | f_frsize
+ "blocks[8]" # Size of FS in f_frsize units | f_blocks | f_blocks
+ "bfree[8]" # Number of free blocks | f_bfree | f_bfree
+ "bavail[8]" # Number of free blocks for unprivileged users | f_bavail | b_avail
+ "files[8]" # Number of inodes | f_files | f_files
+ "ffree[8]" # Number of free inodes | f_ffree | f_ffree
+ # - # Number of free inodes for unprivileged users | - | f_favail
+ "fsid[8]" # Filesystem instance ID | f_fsid | f_fsid
+ # - # Mount flags | f_flags (since Linux 2.6.36) | f_flag
+ "namelen[4]" # Maximum filename length | f_namemax | f_namemax
+msg Tlopen = "size[4,val=end-&size] typ[1,val=12] tag[tag] fid[fid] flags[lo]" # analogous to 112/Topen
+msg Rlopen = "size[4,val=end-&size] typ[1,val=13] tag[tag] qid[qid] iounit[4]" # analogous to 113/Ropen
+msg Tlcreate = "size[4,val=end-&size] typ[1,val=14] tag[tag] fid[fid] name[s] flags[lo] mode[mode] gid[nuid]" # analogous to 114/Tcreate
+msg Rlcreate = "size[4,val=end-&size] typ[1,val=15] tag[tag] qid[qid] iounit[4]" # analogous to 115/Rcreate
+msg Tsymlink = "size[4,val=end-&size] typ[1,val=16] tag[tag] fid[fid] name[s] symtgt[s] gid[nuid]"
+msg Rsymlink = "size[4,val=end-&size] typ[1,val=17] tag[tag] qid[qid]"
+msg Tmknod = "size[4,val=end-&size] typ[1,val=18] tag[tag] dfid[fid] name[s] mode[mode] major[4] minor[4] gid[nuid]"
+msg Rmknod = "size[4,val=end-&size] typ[1,val=19] tag[tag] qid[qid]"
+msg Trename = "size[4,val=end-&size] typ[1,val=20] tag[tag] fid[fid] dfid[fid] name[s]"
+msg Rrename = "size[4,val=end-&size] typ[1,val=21] tag[tag]"
+msg Treadlink = "size[4,val=end-&size] typ[1,val=22] tag[tag] fid[fid]"
+msg Rreadlink = "size[4,val=end-&size] typ[1,val=23] tag[tag] target[s]"
+msg Tgetattr = "size[4,val=end-&size] typ[1,val=24] tag[tag] fid[fid] request_mask[getattr]"
+msg Rgetattr = "size[4,val=end-&size] typ[1,val=25] tag[tag] valid[getattr] qid[qid] mode[mode] uid[nuid] gid[nuid] nlink[8]"
+ "rdev[8] filesize[8] blksize[8] blocks[8]"
+ "atime_sec[8] atime_nsec[8] mtime_sec[8] mtime_nsec[8]"
+ "ctime_sec[8] ctime_nsec[8] btime_sec[8] btime_nsec[8]"
+ "gen[8] data_version[8]"
+msg Tsetattr = "size[4,val=end-&size] typ[1,val=26] tag[tag] fid[fid] valid[setattr] mode[mode] uid[nuid] gid[nuid] filesize[8] atime_sec[8] atime_nsec[8] mtime_sec[8] mtime_nsec[8]"
+msg Rsetattr = "size[4,val=end-&size] typ[1,val=27] tag[tag]"
+#...
+msg Txattrwalk = "size[4,val=end-&size] typ[1,val=30] tag[tag] fid[fid] newfid[fid] name[s]"
+msg Rxattrwalk = "size[4,val=end-&size] typ[1,val=31] tag[tag] attr_size[8]"
+msg Txattrcreate = "size[4,val=end-&size] typ[1,val=32] tag[tag] fid[fid] name[s] attr_size[8] flags[4]"
+msg Rxattrcreate = "size[4,val=end-&size] typ[1,val=33] tag[tag]"
+#...
+msg Treaddir = "size[4,val=end-&size] typ[1,val=40] tag[tag] fid[fid] offset[8] count[4]"
+msg Rreaddir = "size[4,val=end-&size] typ[1,val=41] tag[tag] count[4] count*(data[1])" # data is "qid[qid] offset[8] type[dt] name[s]"
+#...
+msg Tfsync = "size[4,val=end-&size] typ[1,val=50] tag[tag] fid[fid] datasync[b4]"
+msg Rfsync = "size[4,val=end-&size] typ[1,val=51] tag[tag]"
+msg Tlock = "size[4,val=end-&size] typ[1,val=52] tag[tag] fid[fid] type[lock_type] flags[lock_flags] start[8] length[8] proc_id[4] client_id[s]"
+msg Rlock = "size[4,val=end-&size] typ[1,val=53] tag[tag] status[lock_status]"
+msg Tgetlock = "size[4,val=end-&size] typ[1,val=54] tag[tag] fid[fid] type[lock_type] start[8] length[8] proc_id[4] client_id[s]"
+msg Rgetlock = "size[4,val=end-&size] typ[1,val=55] tag[tag] type[lock_type] start[8] length[8] proc_id[4] client_id[s]"
+# ...
+msg Tlink = "size[4,val=end-&size] typ[1,val=70] tag[tag] dfid[fid] fid[fid] name[s]"
+msg Rlink = "size[4,val=end-&size] typ[1,val=71] tag[tag]"
+msg Tmkdir = "size[4,val=end-&size] typ[1,val=72] tag[tag] dfid[fid] name[s] mode[mode] gid[nuid]"
+msg Rmkdir = "size[4,val=end-&size] typ[1,val=73] tag[tag] qid[qid]"
+msg Trenameat = "size[4,val=end-&size] typ[1,val=74] tag[tag] olddirfid[fid] oldname[s] newdirfid[fid] newname[s]"
+msg Rrenameat = "size[4,val=end-&size] typ[1,val=75] tag[tag]"
+msg Tunlinkat = "size[4,val=end-&size] typ[1,val=76] tag[tag] dirfd[fid] name[s] flags[4]"
+msg Runlinkat = "size[4,val=end-&size] typ[1,val=77] tag[tag]"
diff --git a/lib9p/idl/2010-9P2000.L.9p.wip b/lib9p/idl/2010-9P2000.L.9p.wip
deleted file mode 100644
index 5261f7e..0000000
--- a/lib9p/idl/2010-9P2000.L.9p.wip
+++ /dev/null
@@ -1,56 +0,0 @@
-# 2010-9P2000.L.9p - Definitions of 9P2000.L messages
-#
-# Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
-# SPDX-License-Identifier: AGPL-3.0-or-later
-
-# "9P2000.L" Linux extension
-# https://github.com/chaos/diod/blob/master/protocol.md
-version "9P2000.L"
-
-from ./2002-9P2000.9p import *
-from ./2005-9P2000.u.9p import Tauth, Tattach
-
-#msg Tlerror = "size[4,val=end-&size] typ[1,val=6] tag[tag] illegal" # analogous to 106/Terror
-msg Rlerror = "size[4,val=end-&size] typ[1,val=7] tag[tag] ecode[4]" # analogous to 107/Rerror
-msg Tstatfs = "size[4,val=end-&size] typ[1,val=8] tag[tag] TODO"
-msg Rstatfs = "size[4,val=end-&size] typ[1,val=9] tag[tag] TODO"
-msg Tlopen = "size[4,val=end-&size] typ[1,val=12] tag[tag] TODO" # analogous to 112/Topen
-msg Rlopen = "size[4,val=end-&size] typ[1,val=13] tag[tag] TODO" # analogous to 113/Ropen
-msg Tlcreate = "size[4,val=end-&size] typ[1,val=14] tag[tag] TODO" # analogous to 114/Tcreate
-msg Rlcreate = "size[4,val=end-&size] typ[1,val=15] tag[tag] TODO" # analogous to 115/Rcreate
-msg Tsymlink = "size[4,val=end-&size] typ[1,val=16] tag[tag] TODO"
-msg Rsymlink = "size[4,val=end-&size] typ[1,val=17] tag[tag] TODO"
-msg Tmknod = "size[4,val=end-&size] typ[1,val=18] tag[tag] TODO"
-msg Rmknod = "size[4,val=end-&size] typ[1,val=19] tag[tag] TODO"
-msg Trename = "size[4,val=end-&size] typ[1,val=20] tag[tag] TODO"
-msg Rrename = "size[4,val=end-&size] typ[1,val=21] tag[tag] TODO"
-msg Treadlink = "size[4,val=end-&size] typ[1,val=22] tag[tag] TODO"
-msg Rreadlink = "size[4,val=end-&size] typ[1,val=23] tag[tag] TODO"
-msg Tgetattr = "size[4,val=end-&size] typ[1,val=24] tag[tag] TODO"
-msg Rgetattr = "size[4,val=end-&size] typ[1,val=25] tag[tag] TODO"
-msg Tsetattr = "size[4,val=end-&size] typ[1,val=26] tag[tag] TODO"
-msg Rsetattr = "size[4,val=end-&size] typ[1,val=27] tag[tag] TODO"
-#...
-msg Txattrwalk = "size[4,val=end-&size] typ[1,val=30] tag[tag] TODO"
-msg Rxattrwalk = "size[4,val=end-&size] typ[1,val=31] tag[tag] TODO"
-msg Txattrcreate = "size[4,val=end-&size] typ[1,val=32] tag[tag] TODO"
-msg Rxattrcreate = "size[4,val=end-&size] typ[1,val=33] tag[tag] TODO"
-#...
-msg Treaddir = "size[4,val=end-&size] typ[1,val=40] tag[tag] TODO"
-msg Rreaddir = "size[4,val=end-&size] typ[1,val=41] tag[tag] TODO"
-#...
-msg Tfsync = "size[4,val=end-&size] typ[1,val=50] tag[tag] TODO"
-msg Rfsync = "size[4,val=end-&size] typ[1,val=51] tag[tag] TODO"
-msg Tlock = "size[4,val=end-&size] typ[1,val=52] tag[tag] TODO"
-msg Rlock = "size[4,val=end-&size] typ[1,val=53] tag[tag] TODO"
-msg Tgetlock = "size[4,val=end-&size] typ[1,val=54] tag[tag] TODO"
-msg Rgetlock = "size[4,val=end-&size] typ[1,val=55] tag[tag] TODO"
-# ...
-msg Tlink = "size[4,val=end-&size] typ[1,val=70] tag[tag] TODO"
-msg Rlink = "size[4,val=end-&size] typ[1,val=71] tag[tag] TODO"
-msg Tmkdir = "size[4,val=end-&size] typ[1,val=72] tag[tag] TODO"
-msg Tmkdir = "size[4,val=end-&size] typ[1,val=73] tag[tag] TODO"
-msg Trenameat = "size[4,val=end-&size] typ[1,val=74] tag[tag] TODO"
-msg Rrenameat = "size[4,val=end-&size] typ[1,val=75] tag[tag] TODO"
-msg Tunlinkat = "size[4,val=end-&size] typ[1,val=76] tag[tag] TODO"
-msg Runlinkat = "size[4,val=end-&size] typ[1,val=77] tag[tag] TODO"
diff --git a/lib9p/idl/2012-9P2000.e.9p b/lib9p/idl/2012-9P2000.e.9p
index 27db50f..dde9d96 100644
--- a/lib9p/idl/2012-9P2000.e.9p
+++ b/lib9p/idl/2012-9P2000.e.9p
@@ -1,6 +1,6 @@
-# 2012-9P2000.e.9p - Definitions of 9P2000.e messages
+# lib9p/idl/2012-9P2000.e.9p - Definitions of 9P2000.e messages
#
-# Copyright (C) 2024 Luke T. Shumaker <lukeshu@lukeshu.com>
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
# SPDX-License-Identifier: AGPL-3.0-or-later
# "9P2000.e" Erlang extension
@@ -13,6 +13,6 @@ from ./2002-9P2000.9p import *
msg Tsession = "size[4,val=end-&size] typ[1,val=150] tag[tag] key[8]"
msg Rsession = "size[4,val=end-&size] typ[1,val=151] tag[tag]"
msg Tsread = "size[4,val=end-&size] typ[1,val=152] tag[tag] fid[4] nwname[2] nwname*(wname[s])"
-msg Rsread = "size[4,val=end-&size] typ[1,val=153] tag[tag] data[d]"
-msg Tswrite = "size[4,val=end-&size] typ[1,val=154] tag[tag] fid[4] nwname[2] nwname*(wname[s]) data[d]"
+msg Rsread = "size[4,val=end-&size] typ[1,val=153] tag[tag] count[4] count*(data[1])"
+msg Tswrite = "size[4,val=end-&size] typ[1,val=154] tag[tag] fid[4] nwname[2] nwname*(wname[s]) count[4] count*(data[1])"
msg Rswrite = "size[4,val=end-&size] typ[1,val=155] tag[tag] count[4]"
diff --git a/lib9p/idl/__init__.py b/lib9p/idl/__init__.py
new file mode 100644
index 0000000..2d09217
--- /dev/null
+++ b/lib9p/idl/__init__.py
@@ -0,0 +1,878 @@
+# lib9p/idl/__init__.py - A parser for .9p specification files.
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import enum
+import os.path
+import re
+import typing
+
+# pylint: disable=unused-variable
+__all__ = [
+ # entrypoint
+ "Parser",
+ # types
+ "Type",
+ "Primitive",
+ *["Expr", "ExprTok", "ExprOp", "ExprLit", "ExprSym", "ExprOff", "ExprNum"],
+ "Number",
+ *["Bitfield", "Bit", "BitCat", "BitNum", "BitAlias"],
+ *["Struct", "StructMember"],
+ "Message",
+]
+
+# The syntax that this parses is described in `./0000-README.md`.
+
+# Utilities ####################################################################
+
+
+def get_type(env: dict[str, "Type"], name: str, tc: type["T"]) -> "T":
+ if name not in env:
+ raise NameError(f"Unknown type {name!r}")
+ ret = env[name]
+ if (not isinstance(ret, tc)) or (ret.__class__.__name__ != tc.__name__):
+ raise NameError(f"Type {ret.typname!r} is not a {tc.__name__}")
+ return ret
+
+
+# Types ########################################################################
+
+
+class Primitive(enum.Enum):
+ u8 = 1
+ u16 = 2
+ u32 = 4
+ u64 = 8
+
+ @property
+ def in_versions(self) -> set[str]:
+ return set()
+
+ @property
+ def typname(self) -> str:
+ return str(self.value)
+
+ @property
+ def static_size(self) -> int:
+ return self.value
+
+ def min_size(self, version: str) -> int:
+ return self.value
+
+ def max_size(self, version: str) -> int:
+ return self.value
+
+
+class ExprOp:
+ op: typing.Literal["-", "+", "<<"]
+
+ def __init__(self, op: typing.Literal["-", "+", "<<"]) -> None:
+ self.op = op
+
+
+class ExprLit:
+ val: int
+
+ def __init__(self, val: int) -> None:
+ self.val = val
+
+
+class ExprSym:
+ symname: str
+
+ def __init__(self, name: str) -> None:
+ self.symname = name
+
+
+class ExprOff:
+ membname: str
+
+ def __init__(self, name: str) -> None:
+ self.membname = name
+
+
+class ExprNum:
+ numname: str
+ valname: str
+
+ def __init__(self, numname: str, valname: str) -> None:
+ self.numname = numname
+ self.valname = valname
+
+
+type ExprTok = ExprOp | ExprLit | ExprSym | ExprOff | ExprNum
+
+
+class Expr:
+ tokens: typing.Sequence[ExprTok]
+ const: int | None
+
+ def __init__(
+ self, env: dict[str, "Type"], tokens: typing.Sequence[ExprTok] = ()
+ ) -> None:
+ self.tokens = tokens
+ self.const = self._const(env, tokens)
+
+ def _const(
+ self, env: dict[str, "Type"], toks: typing.Sequence[ExprTok]
+ ) -> int | None:
+ if not toks:
+ return None
+
+ def read_val() -> int | None:
+ nonlocal toks
+ assert toks
+ neg = False
+ match toks[0]:
+ case ExprOp(op="-"):
+ neg = True
+ toks = toks[1:]
+ assert not isinstance(toks[0], ExprOp)
+ val: int
+ match toks[0]:
+ case ExprLit():
+ val = toks[0].val
+ case ExprSym():
+ if m := re.fullmatch(r"^u(8|16|32|64)_max$", toks[0].symname):
+ n = int(m.group(1))
+ val = (1 << n) - 1
+ elif m := re.fullmatch(r"^s(8|16|32|64)_max$", toks[0].symname):
+ n = int(m.group(1))
+ val = (1 << (n - 1)) - 1
+ else:
+ return None
+ case ExprOff():
+ return None
+ case ExprNum():
+ num = get_type(env, toks[0].numname, Number)
+ if toks[0].valname not in num.vals:
+ raise NameError(
+ f"Type {toks[0].numname!r} does not have a value {toks[0].valname!r}"
+ )
+ _val = num.vals[toks[0].valname].const
+ if _val is None:
+ return None
+ val = _val
+ toks = toks[1:]
+ return -val if neg else val
+
+ ret = read_val()
+ if ret is None:
+ return None
+ while toks:
+ assert isinstance(toks[0], ExprOp)
+ op = toks[0].op
+ toks = toks[1:]
+ operand = read_val()
+ if operand is None:
+ return None
+ match op:
+ case "+":
+ ret = ret + operand
+ case "-":
+ ret = ret - operand
+ case "<<":
+ ret = ret << operand
+ return ret
+
+ def __bool__(self) -> bool:
+ return len(self.tokens) > 0
+
+
+class Number:
+ typname: str
+ in_versions: set[str]
+
+ prim: Primitive
+
+ vals: dict[str, Expr]
+
+ def __init__(self) -> None:
+ self.in_versions = set()
+ self.vals = {}
+
+ @property
+ def static_size(self) -> int:
+ return self.prim.static_size
+
+ def min_size(self, version: str) -> int:
+ return self.static_size
+
+ def max_size(self, version: str) -> int:
+ return self.static_size
+
+
+class BitAlias:
+ bitname: str
+ in_versions: set[str]
+ val: Expr
+
+ def __init__(self, name: str, val: Expr) -> None:
+ if val.const is None:
+ raise ValueError(f"{name!r} value is not constant")
+ self.bitname = name
+ self.in_versions = set()
+ self.val = val
+
+
+class BitNum:
+ numname: str
+ mask: int
+ vals: dict[str, BitAlias]
+
+ def __init__(self, name: str) -> None:
+ self.numname = name
+ self.mask = 0
+ self.vals = {}
+
+
+type BitCat = typing.Literal["UNUSED", "USED", "RESERVED"] | BitNum
+
+
+class Bit:
+ bitname: str
+ in_versions: set[str]
+ num: int
+ cat: BitCat
+
+ def __init__(self, num: int) -> None:
+ self.bitname = ""
+ self.in_versions = set()
+ self.num = num
+ self.cat = "UNUSED"
+
+
+class Bitfield:
+ typname: str
+ in_versions: set[str]
+ prim: Primitive
+
+ bits: list[Bit]
+ nums: dict[str, BitNum]
+ masks: dict[str, BitAlias]
+ aliases: dict[str, BitAlias]
+
+ names: set[str]
+
+ def __init__(self, name: str, prim: Primitive) -> None:
+ self.typname = name
+ self.in_versions = set()
+ self.prim = prim
+
+ self.bits = [Bit(i) for i in range(prim.static_size * 8)]
+ self.nums = {}
+ self.masks = {}
+ self.aliases = {}
+
+ self.names = set()
+
+ @property
+ def static_size(self) -> int:
+ return self.prim.static_size
+
+ def min_size(self, version: str) -> int:
+ return self.static_size
+
+ def max_size(self, version: str) -> int:
+ return self.static_size
+
+
+class StructMember:
+ # from left-to-right when parsing
+ cnt: "StructMember| int | None" = None
+ membname: str
+ typ: "Type"
+ max: Expr
+ val: Expr
+
+ in_versions: set[str]
+
+ @property
+ def min_cnt(self) -> int:
+ assert self.cnt
+ if isinstance(self.cnt, int):
+ return self.cnt
+ if not isinstance(self.cnt.typ, Primitive):
+ raise ValueError(
+ f"list count must be an integer type: {self.cnt.membname!r}"
+ )
+ if self.cnt.val: # TODO: allow this?
+ raise ValueError(f"list count may not have ,val=: {self.cnt.membname!r}")
+ return 0
+
+ @property
+ def max_cnt(self) -> int:
+ assert self.cnt
+ if isinstance(self.cnt, int):
+ return self.cnt
+ if not isinstance(self.cnt.typ, Primitive):
+ raise ValueError(
+ f"list count must be an integer type: {self.cnt.membname!r}"
+ )
+ if self.cnt.val: # TODO: allow this?
+ raise ValueError(f"list count may not have ,val=: {self.cnt.membname!r}")
+ if self.cnt.max:
+ # TODO: be more flexible?
+ val = self.cnt.max.const
+ if val is None:
+ raise ValueError(
+ f"list count ,max= must be a constant value: {self.cnt.membname!r}"
+ )
+ return val
+ return (1 << (self.cnt.typ.value * 8)) - 1
+
+ @property
+ def static_size(self) -> int | None:
+ if self.cnt:
+ return None
+ return self.typ.static_size
+
+ def min_size(self, version: str) -> int:
+ cnt = self.min_cnt if self.cnt else 1
+ return cnt * self.typ.min_size(version)
+
+ def max_size(self, version: str) -> int:
+ cnt = self.max_cnt if self.cnt else 1
+ return cnt * self.typ.max_size(version)
+
+
+class Struct:
+ typname: str
+ in_versions: set[str]
+
+ members: list[StructMember]
+
+ def __init__(self) -> None:
+ self.in_versions = set()
+
+ @property
+ def static_size(self) -> int | None:
+ size = 0
+ for member in self.members:
+ if member.in_versions < self.in_versions:
+ return None
+ msize = member.static_size
+ if msize is None:
+ return None
+ size += msize
+ return size
+
+ def min_size(self, version: str) -> int:
+ return sum(
+ member.min_size(version)
+ for member in self.members
+ if (version in member.in_versions)
+ )
+
+ def max_size(self, version: str) -> int:
+ return sum(
+ member.max_size(version)
+ for member in self.members
+ if (version in member.in_versions)
+ )
+
+
+class Message(Struct):
+ @property
+ def msgid(self) -> int:
+ assert len(self.members) >= 3
+ assert self.members[1].membname == "typ"
+ assert self.members[1].static_size == 1
+ assert self.members[1].val
+ assert len(self.members[1].val.tokens) == 1
+ assert isinstance(self.members[1].val.tokens[0], ExprLit)
+ return self.members[1].val.tokens[0].val
+
+
+type Type = Primitive | Number | Bitfield | Struct | Message
+type UserType = Number | Bitfield | Struct | Message
+T = typing.TypeVar("T", Number, Bitfield, Struct, Message)
+
+# Parse ########################################################################
+
+# common elements ######################
+
+re_priname = "(?:1|2|4|8)" # primitive names
+re_symname = "(?:[a-zA-Z_][a-zA-Z_0-9]*)" # "symbol" names; most *.9p-defined names
+re_symname_u = "(?:[A-Z_][A-Z_0-9]*)" # upper-case "symbol" names; bit names
+re_symname_l = "(?:[a-z_][a-z_0-9]*)" # lower-case "symbol" names; bit names
+re_impname = r"(?:\*|" + re_symname + ")" # names we can import
+re_msgname = r"(?:[TR][a-zA-Z_0-9]*)" # names a message can be
+
+re_memtype = f"(?:{re_symname}|{re_priname})" # typenames that a struct member can be
+
+valid_syms = [
+ "end",
+ "u8_max",
+ "u16_max",
+ "u32_max",
+ "u64_max",
+ "s8_max",
+ "s16_max",
+ "s32_max",
+ "s64_max",
+]
+
+_re_expr_op = r"(?:-|\+|<<)"
+
+_res_expr_val = {
+ "lit_2": r"0b[01]+",
+ "lit_8": r"0[0-7]+",
+ "lit_10": r"0(?![0-9bxX])|[1-9][0-9]*",
+ "lit_16": r"0[xX][0-9a-fA-F]+",
+ "sym": "|".join(valid_syms), # pre-defined symbols
+ "off": f"&{re_symname}", # offset of a field this struct
+ "num": f"{re_symname}\\.{re_symname}", # `num` values
+}
+
+re_expr_tok = (
+ "(?:"
+ + "|".join(
+ [
+ f"(?P<op>{_re_expr_op})",
+ *[f"(?P<{k}>{v})" for k, v in _res_expr_val.items()],
+ ]
+ )
+ + ")"
+)
+
+_re_expr_val = "(?:" + "|".join(_res_expr_val.values()) + ")"
+
+re_expr = f"(?:\\s*(?:-\\s*)?{_re_expr_val}\\s*(?:{_re_expr_op}\\s*(?:-\\s*)?{_re_expr_val}\\s*)*)"
+
+
+def parse_expr(env: dict[str, Type], expr: str) -> Expr:
+ assert re.fullmatch(re_expr, expr)
+ tokens: list[ExprTok] = []
+ for m in re.finditer(re_expr_tok, expr):
+ if tok := m.group("op"):
+ tokens.append(ExprOp(typing.cast(typing.Literal["-", "+", "<<"], tok)))
+ elif tok := m.group("lit_2"):
+ tokens.append(ExprLit(int(tok[2:], 2)))
+ elif tok := m.group("lit_8"):
+ tokens.append(ExprLit(int(tok[1:], 8)))
+ elif tok := m.group("lit_10"):
+ tokens.append(ExprLit(int(tok, 10)))
+ elif tok := m.group("lit_16"):
+ tokens.append(ExprLit(int(tok[2:], 16)))
+ elif tok := m.group("sym"):
+ tokens.append(ExprSym(tok))
+ elif tok := m.group("off"):
+ tokens.append(ExprOff(tok[1:]))
+ elif tok := m.group("num"):
+ [numname, valname] = tok.split(".", 1)
+ tokens.append(ExprNum(numname, valname))
+ else:
+ assert False
+ return Expr(env, tokens)
+
+
+# numspec ##############################
+
+re_numspec = f"(?P<name>{re_symname})\\s*=\\s*(?P<val>{re_expr})"
+
+
+def parse_numspec(env: dict[str, Type], ver: str, n: Number, spec: str) -> None:
+ spec = spec.strip()
+
+ if m := re.fullmatch(re_numspec, spec):
+ name = m.group("name")
+ if name in n.vals:
+ raise ValueError(f"{n.typname}: name {name!r} already assigned")
+ val = parse_expr(env, m.group("val"))
+ if val is None:
+ raise ValueError(
+ f"{n.typname}: {name!r} value is not constant: {m.group('val')!r}"
+ )
+ n.vals[name] = val
+ else:
+ raise SyntaxError(f"invalid num spec {spec!r}")
+
+
+# bitspec ##############################
+
+re_bitspec_bit = (
+ "bit\\s+(?P<bitnum>[0-9]+)\\s*=\\s*(?:"
+ + "|".join(
+ [
+ f"(?P<name_used>{re_symname_u})",
+ f"reserved\\((?P<name_reserved>{re_symname_u})\\)",
+ f"num\\((?P<name_num>{re_symname_u})\\)",
+ ]
+ )
+ + ")"
+)
+re_bitspec_mask = f"mask\\s+(?P<name>{re_symname_u})\\s*=\\s*(?P<val>{re_expr})"
+re_bitspec_alias = f"alias\\s+(?P<name>{re_symname_u})\\s*=\\s*(?P<val>{re_expr})"
+re_bitspec_num = f"num\\((?P<num>{re_symname_u})\\)\\s+(?P<name>{re_symname_u})\\s*=\\s*(?P<val>{re_expr})"
+
+
+def parse_bitspec(env: dict[str, Type], ver: str, bf: Bitfield, spec: str) -> None:
+ spec = spec.strip()
+
+ def check_name(name: str, is_num: bool = False) -> None:
+ if name == "MASK":
+ raise ValueError(f"{bf.typname}: bit name may not be {'MASK'!r}: {name!r}")
+ if name.endswith("_MASK"):
+ raise ValueError(
+ f"{bf.typname}: bit name may not end with {'_MASK'!r}: {name!r}"
+ )
+ if name in bf.names and not (is_num and name in bf.nums):
+ raise ValueError(f"{bf.typname}: bit name already assigned: {name!r}")
+
+ if m := re.fullmatch(re_bitspec_bit, spec):
+ bitnum = int(m.group("bitnum"))
+ if bitnum < 0 or bitnum >= len(bf.bits):
+ raise ValueError(f"{bf.typname}: bit num {bitnum} out-of-bounds")
+ bit = bf.bits[bitnum]
+ if bit.cat != "UNUSED":
+ raise ValueError(f"{bf.typname}: bit num {bitnum} already assigned")
+ if name := m.group("name_used"):
+ bit.bitname = name
+ bit.cat = "USED"
+ bit.in_versions.add(ver)
+ elif name := m.group("name_reserved"):
+ bit.bitname = name
+ bit.cat = "RESERVED"
+ bit.in_versions.add(ver)
+ elif name := m.group("name_num"):
+ bit.bitname = name
+ if name not in bf.nums:
+ bf.nums[name] = BitNum(name)
+ bf.nums[name].mask |= 1 << bit.num
+ bit.cat = bf.nums[name]
+ bit.in_versions.add(ver)
+ if bit.bitname:
+ check_name(name, isinstance(bit.cat, BitNum))
+ bf.names.add(bit.bitname)
+ elif m := re.fullmatch(re_bitspec_mask, spec):
+ mask = BitAlias(m.group("name"), parse_expr(env, m.group("val")))
+ mask.in_versions.add(ver)
+ check_name(mask.bitname)
+ bf.masks[mask.bitname] = mask
+ bf.names.add(mask.bitname)
+ elif m := re.fullmatch(re_bitspec_alias, spec):
+ alias = BitAlias(m.group("name"), parse_expr(env, m.group("val")))
+ alias.in_versions.add(ver)
+ check_name(alias.bitname)
+ bf.aliases[alias.bitname] = alias
+ bf.names.add(alias.bitname)
+ elif m := re.fullmatch(re_bitspec_num, spec):
+ numname = m.group("num")
+ alias = BitAlias(m.group("name"), parse_expr(env, m.group("val")))
+ alias.in_versions.add(ver)
+ check_name(alias.bitname)
+ if numname not in bf.nums:
+ raise NameError(
+ f"{bf.typname}: nested num not allocated any bits: {numname!r}"
+ )
+ assert alias.val.const is not None
+ if alias.val.const & ~bf.nums[numname].mask:
+ raise ValueError(
+ f"{bf.typname}: {alias.bitname!r} does not fit within bitmask: val={alias.val.const:b} mask={bf.nums[numname].mask}"
+ )
+ bf.nums[numname].vals[alias.bitname] = alias
+ bf.names.add(alias.bitname)
+ else:
+ raise SyntaxError(f"invalid bitfield spec {spec!r}")
+
+
+# struct members #######################
+
+
+re_memberspec = f"(?:(?P<cnt>{re_symname}|[1-9][0-9]*)\\*\\()?(?P<name>{re_symname})\\[(?P<typ>{re_memtype})(?:,max=(?P<max>{re_expr})|,val=(?P<val>{re_expr}))*\\]\\)?"
+
+
+def parse_members(ver: str, env: dict[str, Type], struct: Struct, specs: str) -> None:
+ for spec in specs.split():
+ m = re.fullmatch(re_memberspec, spec)
+ if not m:
+ raise SyntaxError(f"invalid member spec {spec!r}")
+
+ member = StructMember()
+ member.in_versions = {ver}
+
+ member.membname = m.group("name")
+ if any(x.membname == member.membname for x in struct.members):
+ raise ValueError(f"duplicate member name {member.membname!r}")
+
+ if m.group("typ") not in env:
+ raise NameError(f"Unknown type {m.group('typ')!r}")
+ member.typ = env[m.group("typ")]
+
+ if cnt := m.group("cnt"):
+ if cnt.isnumeric():
+ member.cnt = int(cnt)
+ else:
+ if len(struct.members) == 0 or struct.members[-1].membname != cnt:
+ raise ValueError(f"list count must be previous item: {cnt!r}")
+ member.cnt = struct.members[-1]
+ _ = member.max_cnt # force validation
+
+ if maxstr := m.group("max"):
+ if (
+ not isinstance(member.typ, Primitive)
+ and not isinstance(member.typ, Number)
+ ) or member.cnt:
+ raise ValueError(
+ "',max=' may only be specified on a non-repeated numeric type"
+ )
+ member.max = parse_expr(env, maxstr)
+ else:
+ member.max = Expr(env)
+
+ if valstr := m.group("val"):
+ if (
+ not isinstance(member.typ, Primitive)
+ and not isinstance(member.typ, Number)
+ ) or member.cnt:
+ raise ValueError(
+ "',val=' may only be specified on a non-repeated numeric type"
+ )
+ member.val = parse_expr(env, valstr)
+ else:
+ member.val = Expr(env)
+
+ struct.members += [member]
+
+
+# main parser ##########################
+
+
+def re_string(grpname: str) -> str:
+ return f'"(?P<{grpname}>[^"]*)"'
+
+
+re_line_version = f"version\\s+{re_string('version')}"
+re_line_import = f"from\\s+(?P<file>\\S+)\\s+import\\s+(?P<syms>{re_impname}(?:\\s*,\\s*{re_impname})*)"
+re_line_num = f"num\\s+(?P<name>{re_symname})\\s*=\\s*(?P<prim>{re_priname})"
+re_line_bitfield = f"bitfield\\s+(?P<name>{re_symname})\\s*=\\s*(?P<prim>{re_priname})"
+re_line_bitfield_ = (
+ f"bitfield\\s+(?P<name>{re_symname})\\s*\\+=\\s*{re_string('member')}"
+)
+re_line_struct = (
+ f"struct\\s+(?P<name>{re_symname})\\s*(?P<op>\\+?=)\\s*{re_string('members')}"
+)
+re_line_msg = (
+ f"msg\\s+(?P<name>{re_msgname})\\s*(?P<op>\\+?=)\\s*{re_string('members')}"
+)
+re_line_cont = f"\\s+{re_string('specs')}" # could be bitfield/struct/msg
+
+
+def parse_file(
+ filename: str, get_include: typing.Callable[[str], tuple[str, list[UserType]]]
+) -> tuple[str, list[UserType]]:
+ version: str | None = None
+ env: dict[str, Type] = {
+ "1": Primitive.u8,
+ "2": Primitive.u16,
+ "4": Primitive.u32,
+ "8": Primitive.u64,
+ }
+
+ with open(filename, "r", encoding="utf-8") as fh:
+ prev: Type | None = None
+ for lineno, line in enumerate(fh):
+ try:
+ line = line.split("#", 1)[0].rstrip()
+ if not line:
+ continue
+ if m := re.fullmatch(re_line_version, line):
+ if version:
+ raise SyntaxError("must have exactly 1 version line")
+ version = m.group("version")
+ continue
+ if not version:
+ raise SyntaxError("must have exactly 1 version line")
+
+ if m := re.fullmatch(re_line_import, line):
+ other_version, other_typs = get_include(m.group("file"))
+ for symname in m.group("syms").split(sep=","):
+ symname = symname.strip()
+ found = False
+ for typ in other_typs:
+ if symname in (typ.typname, "*"):
+ found = True
+ match typ:
+ case Primitive():
+ pass
+ case Number():
+ typ.in_versions.add(version)
+ case Bitfield():
+ typ.in_versions.add(version)
+ for bf_bit in typ.bits:
+ if other_version in bf_bit.in_versions:
+ bf_bit.in_versions.add(version)
+ for bf_num in typ.nums.values():
+ for bf_val in bf_num.vals.values():
+ if other_version in bf_val.in_versions:
+ bf_val.in_versions.add(version)
+ for bf_mask in typ.masks.values():
+ if other_version in bf_mask.in_versions:
+ bf_mask.in_versions.add(version)
+ for bf_alias in typ.aliases.values():
+ if other_version in bf_alias.in_versions:
+ bf_alias.in_versions.add(version)
+ case Struct(): # and Message()
+ typ.in_versions.add(version)
+ for member in typ.members:
+ if other_version in member.in_versions:
+ member.in_versions.add(version)
+ if typ.typname in env and env[typ.typname] != typ:
+ raise ValueError(
+ f"duplicate type name {typ.typname!r}"
+ )
+ env[typ.typname] = typ
+ if symname != "*" and not found:
+ raise ValueError(
+ f"import: {m.group('file')}: no symbol {symname!r}"
+ )
+ elif m := re.fullmatch(re_line_num, line):
+ num = Number()
+ num.typname = m.group("name")
+ num.in_versions.add(version)
+
+ prim = env[m.group("prim")]
+ assert isinstance(prim, Primitive)
+ num.prim = prim
+
+ if num.typname in env:
+ raise ValueError(f"duplicate type name {num.typname!r}")
+ env[num.typname] = num
+ prev = num
+ elif m := re.fullmatch(re_line_bitfield, line):
+ prim = env[m.group("prim")]
+ assert isinstance(prim, Primitive)
+
+ bf = Bitfield(m.group("name"), prim)
+ bf.in_versions.add(version)
+
+ if bf.typname in env:
+ raise ValueError(f"duplicate type name {bf.typname!r}")
+ env[bf.typname] = bf
+ prev = bf
+ elif m := re.fullmatch(re_line_bitfield_, line):
+ bf = get_type(env, m.group("name"), Bitfield)
+ parse_bitspec(env, version, bf, m.group("member"))
+
+ prev = bf
+ elif m := re.fullmatch(re_line_struct, line):
+ match m.group("op"):
+ case "=":
+ struct = Struct()
+ struct.typname = m.group("name")
+ struct.in_versions.add(version)
+ struct.members = []
+ parse_members(version, env, struct, m.group("members"))
+
+ if struct.typname in env:
+ raise ValueError(
+ f"duplicate type name {struct.typname!r}"
+ )
+ env[struct.typname] = struct
+ prev = struct
+ case "+=":
+ struct = get_type(env, m.group("name"), Struct)
+ parse_members(version, env, struct, m.group("members"))
+
+ prev = struct
+ elif m := re.fullmatch(re_line_msg, line):
+ match m.group("op"):
+ case "=":
+ msg = Message()
+ msg.typname = m.group("name")
+ msg.in_versions.add(version)
+ msg.members = []
+ parse_members(version, env, msg, m.group("members"))
+
+ if msg.typname in env:
+ raise ValueError(f"duplicate type name {msg.typname!r}")
+ env[msg.typname] = msg
+ prev = msg
+ case "+=":
+ msg = get_type(env, m.group("name"), Message)
+ parse_members(version, env, msg, m.group("members"))
+
+ prev = msg
+ elif m := re.fullmatch(re_line_cont, line):
+ match prev:
+ case Bitfield():
+ parse_bitspec(env, version, prev, m.group("specs"))
+ case Number():
+ parse_numspec(env, version, prev, m.group("specs"))
+ case Struct(): # and Message()
+ parse_members(version, env, prev, m.group("specs"))
+ case _:
+ raise SyntaxError(
+ "continuation line must come after a bitfield, struct, or msg line"
+ )
+ else:
+ raise SyntaxError("invalid line")
+ except (SyntaxError, NameError, ValueError) as e:
+ e2 = SyntaxError(str(e))
+ e2.filename = filename
+ e2.lineno = lineno + 1
+ e2.text = line
+ raise e2 from e
+ if not version:
+ raise SyntaxError("must have exactly 1 version line")
+
+ typs: list[UserType] = [x for x in env.values() if not isinstance(x, Primitive)]
+
+ for typ in [typ for typ in typs if isinstance(typ, Struct)]:
+ for member in typ.members:
+ if (
+ not isinstance(member.typ, Primitive)
+ and member.typ.in_versions < member.in_versions
+ ):
+ raise ValueError(
+ f"{typ.typname}.{member.membname}: type {member.typ.typname} does not exist in {member.in_versions.difference(member.typ.in_versions)}"
+ )
+ for tok in [*member.max.tokens, *member.val.tokens]:
+ if isinstance(tok, ExprOff) and not any(
+ m.membname == tok.membname for m in typ.members
+ ):
+ raise NameError(
+ f"{typ.typname}.{member.membname}: invalid offset: &{tok.membname}"
+ )
+
+ return version, typs
+
+
+# Filesystem ###################################################################
+
+
+class Parser:
+ cache: dict[str, tuple[str, list[UserType]]] = {}
+
+ def parse_file(self, filename: str) -> tuple[str, list[UserType]]:
+ filename = os.path.normpath(filename)
+ if filename not in self.cache:
+
+ def get_include(other_filename: str) -> tuple[str, list[UserType]]:
+ return self.parse_file(os.path.join(filename, "..", other_filename))
+
+ self.cache[filename] = parse_file(filename, get_include)
+ return self.cache[filename]
+
+ def all(self) -> tuple[set[str], list[UserType]]:
+ ret_versions: set[str] = set()
+ ret_typs: dict[str, UserType] = {}
+ for version, typs in self.cache.values():
+ if version in ret_versions:
+ raise ValueError(f"duplicate protocol version {version!r}")
+ ret_versions.add(version)
+ for typ in typs:
+ if typ.typname in ret_typs:
+ if typ != ret_typs[typ.typname]:
+ raise ValueError(f"duplicate type name {typ.typname!r}")
+ else:
+ ret_typs[typ.typname] = typ
+ msgids: set[int] = set()
+ for typ in ret_typs.values():
+ if isinstance(typ, Message):
+ if typ.msgid in msgids:
+ raise ValueError(f"duplicate msgid {typ.msgid!r}")
+ msgids.add(typ.msgid)
+ return ret_versions, list(ret_typs.values())