<cr1901>
(Requires pytest) This simulation infinitely loops. Why doesn't it terminate after 1 cycle?
<cr1901>
To avoid XY Problem: I don't actually care about the module to simulate, other than the Simulator signature requires it and I want to use a stub one for minimizing.
<cr1901>
Answer: Because my stupid ass forgot to add a clock
<cr1901>
Obviously it's because "process order isn't deterministic at a particular tick", but what's different about "yield" that makes all the cases where mk_sig=True pass the assert?
Guest1 has joined #amaranth-lang
Guest1 has quit [Client Quit]
<charlottia>
cr1901: afaict it depends on whether `in_proc` or `take_proc` gets run first
<charlottia>
If take_proc runs first, then it runs through the foo.eq(0) branch before yielding; then in_proc runs, does set = True, yields. take_proc continues on in the loop, does foo.eq(1), yields. in_proc resumes at 66, and foo still hasn't actually taken the value 1 yet.
<charlottia>
OTOH, if in_proc runs first, it immediately does set = True and yields. take_proc then starts for the first time, does foo.eq(1). When in_proc resumes, it already sees the new value of foo. I guess maybe because it didn't have hit .eq(0) to begin with?
<charlottia>
I haven't looked at how those changes propagate/are scheduled internally, but I assume it has to do with the relative number of times each has ticked?
<charlottia>
As for why sometimes one runs first and sometimes the other, the processes are internally stored in a set(), and are run each step just by iterating that set. Non-determinism is expected, sets don't have a specified order and iirc use hash randomisation.
<FL4SHK>
feature request for `View`, `Struct`, `Union`, etc.: `__len__(self)` method
<FL4SHK>
perhaps there si another way to do that
<galibert[m]>
that would be the length of what?
<FL4SHK>
the whole packed struct
<galibert[m]>
in bits?
<FL4SHK>
yes
<galibert[m]>
don't you have that through the shape?
<FL4SHK>
how do I access that with a `View`?
<FL4SHK>
oh, looks like I can just do `Shape.cast(layout)`
<FL4SHK>
and that'll tell me
<galibert[m]>
yeah, I was looking at how to reach the layout from the view, which seems nontrivial
<FL4SHK>
yeah, so with the `View` itself, it looks not doable
<galibert[m]>
I suspect you can do an ugly with .as_value().shape()
<FL4SHK>
that's not that bad
<galibert[m]>
I think eventally ValueCastable will have .shape() itself, which will give the original layout
<FL4SHK>
that'd be good
<FL4SHK>
Value.cast(my_view).shape()
<FL4SHK>
k
<whitequark>
galibert: Layout.of(view)
<whitequark>
but yes, this will be done as ValueCastable.shape() as soon as I write another RFC
<galibert[m]>
Oh yeah, forgot that one
<whitequark>
<cr1901> "Answer: Because my stupid ass..." <- I think we have a warning for that? Maybe an open issue
<cr1901>
charlottia: That is indeed what I'm seeing. I was trying to knock a useless cycle off waiting for set to propagate in my real code and thought "I know, I'll make 'set' a regular Python variable!" Nope. Makes the code racy, as demonstrated :(.
<cr1901>
You can understand why I thought "I think we have a warning for that" meant "this is already implemented", yes?
<cr1901>
the word sequence was not a typo <-- My brain subconsciously swapped "an" and "open"
<whitequark>
ah
<FL4SHK>
whitequark: `Layout.of()` is very much something I need, so thanks for pointing that out
<whitequark>
it's right there in the doc! I dont understand why people keep missing it
<FL4SHK>
I am bad about reading thoroughly
<whitequark>
not just you
<whitequark>
it's multiple people
<whitequark>
clearly something is wrong with the doc but I've no idea what
<FL4SHK>
no, I just didn't see it in the doc
<FL4SHK>
or I forgot
<whitequark>
one person is "just didn't see", two is "couldn't notice", three is an issue with the doc!
<whitequark>
that's my approach at least
<FL4SHK>
ah, I see
sugarbeet has quit [Ping timeout: 250 seconds]
sugarbeet has joined #amaranth-lang
<miek>
i guess i would initially expect to find it as a method on the View, so i wouldn't go looking for the Layout API docs
<whitequark>
right, and it will be a method on the View
<whitequark>
so that will solve itself
<miek>
ah, great!
<miek>
also, when skimming the API docs, it wasn't immediately clear to me what `of` does. it was only after seeing it in context and reading it aloud that i realised "Layout.of(xyz)" should be read as "the layout of xyz". i suppose i'm used to seeing names like `from_view` or similar
<whitequark>
so I propose we use this meeting time to discuss concerns and questions about the interfaces RFC
<whitequark>
I've just pushed the updated text a few minutes ago, which includes a beginning of the proper guide level explanation
<whitequark>
note that it is currently, I think, a bit out of sync with the reference level explanation
<whitequark>
I think what I'll do is to describe the concepts behind the interfaces RFC in what I think will likely be its final form
<cr1901>
I still didn't get chance to really read it so I'll use this time to read and listen
<whitequark>
conceptually, what is an "interface"? it is a collection of fields on an object, and possibly sub-objects with their own fields, that are all value-castable; that have a specific shape; and that have a logical direction assigned to them
<whitequark>
people have been using interfaces in Amaranth for as long as it existed in the form of connections between submodules, but the interfaces were implicit
<whitequark>
this works surprisingly well, but does not scale well to large and complex designs
<whitequark>
the interfaces RFC doesn't really introduce any new underlying concepts; instead it provides a formalism that makes the existing concepts that people use every day accessible programmatically
<whitequark>
in the latest edition, I've actually removed the Interface class. if you ask a default Signature to make an instance of itself, it starts with a Python object() and adds fields to it. it'll probably need to set some default __repr__ too
<whitequark>
basically, any object can have interfaces, and the library doesn't have opinions on what it should be, what it inherits from, etc, so the default implementation gives you just "an object".
<whitequark>
it is expected that most SoC classes for example will override that to create instances of their own classes, or at least add their own fields
<whitequark>
however, if you already have some sort of object, maybe an Elaboratable, that has a bunch of Signal fields on it? congratulations, you have an interface compliant with the Interfaces RFC.
<whitequark>
this keeps churn to a minimum and avoids dictating what people should do in a place where it's not really necessary
<whitequark>
oh, sorry, I've missed one part
<whitequark>
it's not quite compliant yet. to make it compliant, it needs to have a .signature property. it could be a class property, instance property, @property decorated member, whatever
<whitequark>
and it must return something that makes obj.signature.compatible(obj) == True for it to be a valid interface
<whitequark>
I know people have actually been doing this themselves for elaboratables already, something like def ports(self): return [self.x...]
<cr1901>
Ahhh yes, that's a classic :)
<whitequark>
now you have an official™ way of doing that, and it gives you the ability to use the .connect function, in some cases at least
<whitequark>
usually connect on a bare elaboratable won't be that useful
<whitequark>
I think that's about it; the RFC that I'll finish in a few days will properly formalize that
<whitequark>
questions?
<Chips4MakersakaS>
Where does logical direction comes into play, AFAICR this is not part of classic object with Signal fields.
<whitequark>
when using .connect
<whitequark>
so you would have `<name-of-module-tbd>.connect(m, initiator.wishbone, target.wishbone)`
<whitequark>
and it does all of the m.d.comb += for you
<jfng[m]>
> creating an interface always creates all sub-interfaces and fields a. but you can freely reassign them, since there's nothing special about them
<jfng[m]>
to do so, a user would still have to assemble the field names themselves ? just like for Records ?
<Chips4MakersakaS>
And the logical direction of fields is defined by the .signature property ?
<whitequark>
Chips4Makers (aka Staf Verhaegen): of whatever you pass to `connect` (in this case `initiator.wishbone.signature` and `target.wishbone.signature`), yes
<Chips4MakersakaS>
understood
<whitequark>
jfng: yeah I think so, I imagine it's usually a matter of overriding one or two fields when you're reexporting an interface. I'm waiting on the RFC to be actually used before committing to any enhancements in that area
<jfng[m]>
> and it must return something that makes obj.signature.compatible(obj) == True for it to be a valid interface
<jfng[m]>
can the rules of `.compatible()` be overriden ? e.g. to implement the "wired-OR" behavior
<whitequark>
no
<whitequark>
however the wired-OR or wired-AND behavior can be added in a future RFC if it's sufficiently requested
<whitequark>
I think it could be mainly useful for implementing "virtual I2C" on the chip
<whitequark>
and other things like that
<d1b2>
<zyp> I like the change from an Interface class to arbitrary types
<whitequark>
you also cannot make connect connect two interfaces that do not have exactly inverse signatures
<whitequark>
there is no include or exclude, if you need something that is not a plain connection as described by the object itself, you have to write it by hand
<whitequark>
hopefully with an explanation of why this is a valid way to do a connection
<jfng[m]>
wired-OR would allow a wishbone interface that implements CTI/BTE to be compatible with one that doesn't
<jfng[m]>
wait no
<jfng[m]>
i misunderstand
<jfng[m]>
let me rephrase, it has nothing to do with wired-OR
<jfng[m]>
wishbone interfaces with some optional features such as CTI/BTE/etc. may still be compatible with other that don't
<cr1901>
Why no include/exclude?
<jfng[m]>
so my question is, would this be supported by .compatible() ?
<whitequark>
cr1901: `connect` not meant as a *shorthand*. instead it is a way to say "this is a 'normal' connection between two modules exactly as it was intended by their designers". it means you can stop looking there for bugs, basically
<whitequark>
meanwhile if you have include/exclude that advantage is gone
<whitequark>
this is also partially answer to jfng's query
<whitequark>
s//@/, s///
<whitequark>
I worked out a way to support optional features of interfaces uh... a year ago, and I might've lost the code? anyway, the outline here is
<whitequark>
an interface can be "upgraded" (or "downgraded"). if you map out which feature sets are compatible with which, it is a lattice, right?
<whitequark>
so you add a function that moves the interface in a certain direction in a lattice
<whitequark>
IIRC, the way I made it is that you can add a function on the interface class that alters its signature in the direction of adding stubs for optional features
<cr1901>
I would have to see a picture to visualize this (I can see the wiki article has pictures, but I meant an example of your interface upgrade/downgrade w/ a lattice picture)
<cr1901>
It's fine if you don't have one
<whitequark>
eg if your module doesn't properly support CTI/BTE, but it effectively drives those with constant signals whenever they're available, you can say ".add_options('cti', 'bte')"
<whitequark>
this adds those fields to a signature, and adds Const attributes to the interface object
<whitequark>
those Const attributes are always valid as Out direction members, but only valid as In direction members if the constant is the same on both sides
<cr1901>
meaning if one side is a Value, the other side is a Const, it's invalid
<jfng[m]>
nice
<whitequark>
cr1901: only if the Value side is an Out and the Const side is an In
<jfng[m]>
a const input, with a non-const output would be invalid
<Chips4MakersakaS>
Connecting wishbone interfaces with and without stall needs logic, not just constants. Is that supported?
<jfng[m]>
that would require a bridge, no ?
<whitequark>
not in connect; you need to manually instantiate a module
<whitequark>
I think it is a good rule that connect should never add a register, ever
<whitequark>
(it would be an absolute nightmare to reason about)
<Chips4MakersakaS>
Agree it should be fully combinatorial
<cr1901>
Also, connecting wishbone shared/crossbar will require WIRED AND (for muxing initiator data inputs from targets) and OR (for ORing the CYC line). It also can't handle chip select on the STB line, which will exist between initiator and target
<whitequark>
connect does not replace crossbars
<whitequark>
it replaces manually adding endless m.d.comb += to connect to the crossbar
<whitequark>
well, in the case of a crossbar, you actually don't have that since you have add_target
<whitequark>
it's more useful for things where we don't necessarily have well defined crossbars, like pins, streams, and so on
<whitequark>
but I think it might be useful for connecting the CPU to the crossbar, too (we'll need to think the methodology on that through)
<cr1901>
Please note that I'm using the wishbone definition of shared and crossbar; does connect replaced WB shared buses? If not, I think an RFC that adds WIRED AND and OR should also add a chip_select for address decoding.
<whitequark>
I do not think connect should be involved in address decoding
<whitequark>
or, rather, I know it has no business being near that
<whitequark>
we have memory maps that need to be maintained and this requires a dedicated object
<d1b2>
<zyp> I'm thinking about composing and decomposing interfaces, considering e.g. something like https://paste.jvnv.net/view/GH04h, and for that to work sanely, the parent must use Flow.Out for everything, otherwise connect(m, a, b) and connect(m, a.x, b.x) would behave differently
<d1b2>
<zyp> it feels like there might be a gotcha there, but I haven't found something specific to point at
<cr1901>
how does this prevent proliferation of manually doing comb then, since the moment "you need something that is not a plain connection as described by the object itself [or is an option], you have to write it by hand"
<whitequark>
zyp: oh, this is a good point, actually
<whitequark>
cr1901: why are you manually writing chip select logic for wishbone buses?
<whitequark>
we have a perfectly good wishbone.Decoder right here
<d1b2>
<zyp> if two interfaces don't match up exactly, you instance a gasket to go in between and use connect() on either side
<whitequark>
yep
<d1b2>
<zyp> and for common stuff, the gaskets could be instanced by methods on the interfaces themselves
<whitequark>
I'm a bit wary of that but yeah something like that
<whitequark>
re: connecting sub-interfaces, I think we might need something that updates the signature if you refer to a sub-interface
<whitequark>
this is very valuable feedback and I'm glad I got it now
<cr1901>
I don't know how amaranth-soc works; how would this Interfaces RFC work with amaranth_soc.wishbone? 1/2
<jfng[m]>
iirc, litex streams force the signal directions of their "payload" fields as fanouts
<whitequark>
I think we'll be able to do better than litex :)
<cr1901>
Wishbone sometimes is an interface where you can directly connect both sides without any intermediate logic between them. Sometimes it isn't (shared, crossbar). It seems sucky to lose out on Interfaces or have to do seemingly a bespoke API like the one in amaranth_soc.wishbone to avoid m.d.comb += proliferation all over the place in the cases where two sides of the Interface don't perfectly connect.
<whitequark>
uh, amaranth_soc.wishbone has dedicated Decoder and Arbiter (and Crossbar) modules that let you connect multiple buses
<cr1901>
You may think it's fine to do a bespoke solution to avoid m.d.comb += proliferation for buses that don't meet the narrow requirements of an Interface
<whitequark>
any other commonly used intermediate logic should likewise have its own module doing this, because *this is necessary for maintaining memory maps*
<whitequark>
for SoC applications, memory maps are not optional. if you connect Wishbone buses with m.d.comb, you will not get a BSP part generated for anything behind that connection
<whitequark>
and for everyone else they cut down on repetition
<whitequark>
connect for Wishbone will handle exactly one case: that of connecting two identical Wishbone buses where a CPU isn't on die
<whitequark>
for everything else, you instantiate a Decoder, an Arbiter, or something like that from the wishbone_soc catalog
<jfng[m]>
memory maps are also capable of detecting overlaps between e.g. the regions of two targets of a Decoder
<whitequark>
yeah
<whitequark>
and now I remember why we have decoder.add_target: it's to handle memory maps
<cr1901>
We're getting too hung up on Wishbone; I get it now. You will have to "just deal" with the repetition if you want to connect two sides of an Interface which require some intermediate logic between them, and hopefully you can spin out a usable method/function
<whitequark>
correct
<cr1901>
so you only have to do the m.d.comb dance once
<whitequark>
.connect is basically .eq that respects directions
<whitequark>
talking about .connect handling Wishbone decoding makes about as much sense as talking about .eq doing it
<cr1901>
.mostly_connect
<Chips4MakersakaS>
So it also will not handle to connection of rx/tx between UART intwrfaces...
<jfng[m]>
would it be possible for amaranth backends to automatically use the top module signature as ports ?
<jfng[m]>
as in, desirable
<whitequark>
Chips4Makers: we discussed that previously with .zyp, and my argument is that this is only meaningful for cut-down UART interfaces that aren't full RS232
<whitequark>
ie, once you have .rts and .dtr and everything else, you no longer have a very well defined way to connect two identical ones
<whitequark>
and if you just have .rx and .tx, I think writing out 2 lines of code connecting them is fine
<whitequark>
exclude is a terrible concept and it should've never been introduced
<d1b2>
<zyp> also, if you have a more complex, bidirectional interface that consists of two identical halves, you could decompose it into connect(m, a.tx, b.rx); connect(m, a.rx, b.tx)
<whitequark>
yep!
<whitequark>
and if that isn't legal for the interface, you probably need a gasket
<Chips4MakersakaS>
@zyp good, I think you have that in *MII, ULPI etc/
<Chips4MakersakaS>
Normally it's only for off-chip but I don't exclude having PHY on-chip in the end.
<whitequark>
I don't think that's right? for MAC-PHY connection you use normal .connect
<whitequark>
since the PHY and the MAC have complementary signal directions
<whitequark>
this issue would only come up if you want to do a MAC-MAC connection
<whitequark>
which while not impossible and certainly worth supproting in some sense, is quite rare to do on the chip
<whitequark>
when was the last time you had two Ethernet cores on chip that were talking to each other?
<whitequark>
I'm not even sure I've ever had that with UART
<d1b2>
<zyp> I have multiple PHYs chained
<whitequark>
could you explain?
<whitequark>
is it like an Ethernet redriver?
<whitequark>
s/redriver/retimer/
<Chips4MakersakaS>
You can have ethernet switch but that is similar to Wishbone Arbiter, Crossbar etc.
<d1b2>
<zyp> I'm doing an ethercat-like industrial protocol, where the FPGA implementation ends up being a MAC and any number of PHYs chained in a ring
<d1b2>
<zyp> but at the point where the chaining happens, it's not *MII anymore, it's litex streams
<whitequark>
well, yes, that was the other thing I wanted to say
<whitequark>
if two cores want to talk bytes to each other, they should export streams
<whitequark>
serializing UART just to immediately deserialize it is ... wasteful and pointless?
<whitequark>
why would you possibly want that?
<d1b2>
<zyp> but you could want to connect a bidirectional stream pair together 🙂
<whitequark>
this is true, but I think "two lines instead of one" is not too much to inflict on Amaranth developers
<whitequark>
we could consider ways to do this though
<whitequark>
I need to catch a train but once I'm on a train let's continue this discussion while people are available
<whitequark>
I'm especially interested in thinking up solutions for the sub-interface connection problem since that will be uh pretty critical to success
<whitequark>
I think I have an idea, though
<whitequark>
(gotta run)
<Chips4MakersakaS>
I'll go AFK ...
<d1b2>
<zyp> I guess if you've got a specific class to represent a bidirectional stream pair or similar, that could be one use for a method-instanced gasket that basically just swaps tx and rx
<d1b2>
<zyp> connect(m, a, b.nullmodem()) for lack of a better name 🙂
<cr1901>
Can we use some of the Terminology Budget to make Gasket official?
<whitequark>
that's the idea, yes
<whitequark>
to both, actually
<jfng[m]>
<whitequark> "I'm especially interested in..." <- i'm not sure to have followed from the backlog, could you reformulate the problem ?
<galibert[m]>
I don't think, or possibly I hope, that the MAC and the PHY will not have written down inverted interfaces. I hope there will be an interface defined somewhere once, and MACs use it in one direction and PHYs in the other
<galibert[m]>
similarly, I see connect in wishbone used to connect a cpu port to an arbiter port, or a decoder port to a device
<galibert[m]>
perhaps a slightly modified one for decoder-device
<galibert[m]>
(with the intriging question of how to handle different address widths)
<whitequark>
galibert: yes, this is what In/Out on sub-interfaces is actually for
<galibert[m]>
Good, that makes sense
<whitequark>
you define an interface once, if you use it with Out direction as a sub-interface of something, it exists as-is, if you use it with In direction, it is flipped
<whitequark>
so you define a MII signature once, from MAC perspective
<whitequark>
and for PHY you use In[MIISignature] or something
<galibert[m]>
Also, not a troll even if it may sound as one, are there uses of Struct that are not better as Interfaces? What's the use case for Struct once Interfaces exists?
<whitequark>
Struct is for data, Interface is for logic
<whitequark>
and in fact Interface will often include Struct
<whitequark>
Streams would have a .payload or .data or something member that will often be a Struct
<whitequark>
so you'd have eg Stream[8b10bWord] or something
<whitequark>
then you can do stream.payload.d and stream.payload.k
<galibert[m]>
and that will be a struct and not an interface?
<whitequark>
correct. because the individual fields do not have directionality
<whitequark>
this is something that Chisel gets wrong
<galibert[m]>
streams are directional through aren't they?
<whitequark>
the *individual fields* of the payload do not have their own directionality
<whitequark>
only all of the payload in aggregate
<galibert[m]>
sure, but the endpoints don't want to have to plonk a pile-of-eq, they're gonna want some kind of do-it-all-at-the-same-time connect
<whitequark>
I don't understand
<whitequark>
views have a .eq defined on them that assigns the entire underlying storage all at once
<whitequark>
that's the point of separating them from interfaces, actually
<whitequark>
<jfng[m]> "i'm not sure to have followed..." <- jfng: let me elaborate on both the problem and the solution
<whitequark>
the problem: suppose you have a PHY that has mii : In[MIIInterface]. if you could do connect(m, mac, phy) that would be fine since connect would invert the directions. but you are probably doing connect(m, mac.mii, phy.mii) and if implemented naively, then since both use the same MIIInterface (see answer to galibert's question), they would have incompatible directions and connect will fail
<galibert[m]>
So the differences between interfaces and data is the connection syntax (a.f(b) vs. commutative f(a, b)), and the fact that interfaces handle directionality
<whitequark>
jfng: this is easy to solve though by making `phy.signature["mii"]` and `phy.mii.signature` return the same object, i.e. flipping the signature of mii when instantiating it
<whitequark>
galibert: yes, and these two are basically the same difference
<galibert[m]>
yes
<whitequark>
zyp: so I guess flipping signatures is a rather core operation after all
<whitequark>
both for evert() and because we're going to pass them around
<whitequark>
I think I'll handle that using a proxy object class flipped: that uses getattribute to pass everything through to the original signature, except it knows how to flip getitem, or deriving from signature will get broken
<jfng[m]>
so a member must keep a back reference to its parent signature ?
<whitequark>
kinda, but you would be using them differently
<whitequark>
in particular you will routinely derive from Signature and you dont really derive from Layout nor add anything to it
<whitequark>
jfng: no, I don't think so; why?
<jfng[m]>
how will phy.mii.signature know that it is a member of phy.signature, so it can be flipped ?
<galibert[m]>
Ok, I need to read the part of the rfc that tells why you would derive from signature :-)
<jfng[m]>
(and so on, this could nest arbitrarily deep)
<whitequark>
jfng: it won't. whatever creates the object that goes to phy.mii will put an already flipped object there
<whitequark>
I guess the way this will be implemented is by having `phy.mii.signature = phy.signature["mii"].flip()` which returns a proxy object
<jfng[m]>
so the instantiation of signatures will happen top-down ?
<whitequark>
meaning, `phy.mii.signature.x` and `phy.signature["mii"].x` refers to the exact same attribute
<whitequark>
jfng: yes
<whitequark>
necessarily
<whitequark>
since you don't know if you need to flip otherwise
<jfng[m]>
ah, yes, signal names depend on the full concatenated path
<galibert[m]>
You have Out[...] and In[...] to build Interfaces struct-like, could you have sub-signatures and Flip[signature] too?
<jfng[m]>
and also the Consts for optional members would need knowledge of the direction, so top-down is really the only way
<whitequark>
galibert: Out and In applies to signatures too
<cr1901>
subinterface == "define a logical grouping of signals that constitute an Interface in a parent module, and each child module connects() only a _nonoverlapping_ subset of signals in the parent Interface?
<whitequark>
Out is a no-op for a signature and In flips it
<whitequark>
this is actually explained at length in the RFC
<cr1901>
this is actually explained at length in the RFC <-- subinterfaces? Or still replying to galibert[m]?
<whitequark>
replying to galibert
<cr1901>
Is my understanding of subinterfaces correct?
<whitequark>
there is no such thing currently in the RFC
<cr1901>
>I'm especially interested in thinking up solutions for the sub-interface connection problem since that will be uh pretty critical to success <-- Ahh, so no concrete definition yet
<whitequark>
no I meant there is no such thing as what you've described the behavior of
<whitequark>
please read the RFC section on signatures
<whitequark>
could we introduce that thing? maybe, I've thought about it
<whitequark>
but there isn't one rn
<cr1901>
Indeed I haven't read the RFC fully, so I'll wait before commenting more. I think my high-level/low-hanging questions are done.
<whitequark>
this conversation has been exceptionally useful and productive, thanks everyone
<cr1901>
I don't know what's wrong with my brain today, but having trouble grokking why we need _two_ separate classes Member and Signature. I guess I'll try again after a break.
<cr1901>
(Sorry I don't have any concrete questions to ask. I think amaranth just has a learning curve for me.)
<whitequark>
you could theoretically do it with one but subclassing would get real weird