- Author: Douglas P. Fields, Jr - symbolics@lisp.engineer
This document contains some notes on how this was implemented, how various implementation challenges were met (or not), and notes about how to do things in the future.
It's very complicated to use a third party library right now in DotCL and this application, and have them available in the binary.
-
Download the library using an SBCL with QuickLisp, because QuickLisp does not yet support DotCL.
-
Then load the package with the DotCL built-in ASDF. However, it requires some strange manner to make it work so it can be used in the normal build process as well as loading from a REPL. See
load-system-test.lispfor details. -
However, that doesn't make it possible for the binary to get these files when transferred to another system. So, the source needs to be copied to the binary output in a
contribdirectory. I've added a section to the.csprojthat does that.
TO DO: See if it is possible to have the DotCL compiled FASL files used instead?
I've submitted a request with DotCL to automate this somehow.
(require "asdf") fails at runtime with the error
DotCL.LispErrorException: REQUIRE: module "asdf" not found
due to a combination of two issues:
-
ASDF is skipped during build dependency bundling: Standard Lisp libraries in DotCL (like ASDF) do not define individual
:componentschildren and haveNILpathnames (since they are precompiled and shipped directly with the compiler). This causes DotCL's--resolve-depstool to treat ASDF as a built-in package and skip outputting it to the bundle manifest (dotcl-deps.txt). As a result, MSBuild never copied the prebuiltasdf.faslinto the game's output directory. -
Missing
contribdirectory at runtime: At runtime, DotCL'sMODULE-PROVIDE-CONTRIBfunction dynamically searches for modules in acontrib/subdirectory relative to the executing process'sAppContext.BaseDirectory(which for the game isbin/Debug/net10.0/ubuntu.24.04-x64/). Because thecontrib/folder was not copied to the build output directory, DotCL was unable to locateasdf.fasldynamically.
-
How do I pass a boolean "False" to a C# function with
dotnet:invoke?- Do I just send in
nil? - Try
(dotnet:box nil "BOOL")as in(dotnet:invoke an-object "AMethod" (dotnet:box nil "BOOL"))
- Do I just send in
-
Is there a fast way to invoke C# functions/methods without going through introspection? I.e., a way to say "save the method call to X(a1, a2, a3)" and then invoke that method call on an object quickly with minimal overhead, preferably as if it were being directly invoked?
- RESOLVED: DotCL 0.1.11 introduced direct method calls via the
(the (dotnet "Type") obj)type declaration syntax.
- RESOLVED: DotCL 0.1.11 introduced direct method calls via the
-
In
dotnet:define-class, how would I specify a field of classFunc<object, object[], object>?
-
Add constructor arguments to
dotnet:define-class.- Changed to "support multiple constructors", because SANO-san implemented the constructor arguments
-
- CLOSED: SANO-san implemented this
-
- CLOSED: SANO-san implemented this
-
Quicklisp: SBCL can be used to load Quicklisp packages. As of now, Quicklisp does not load in DotCL (there are DotCL language issues and also needs a DotCL-specific socket implementation). However, once loaded in SBCL, it is possible to use DotCL's ASDF to load those systems, e.g.,
(asdf:load-system "dungeon-slime").- I may work on the Quicklisp DotCL implementation at some point.
-
MGCB: To copy the
test-atlas.lispfile to the output Content directory, add it to the.mgcbfile and change theBuild ActiontoCopy. -
To run the game as another user: (Remember I use Kubuntu 24.04 under Wayland)
cp -a bin/Debug/net10.0/ubuntu.24.04-x64/ /tmp(or wherever)xhost +si:localuser:dummysu - dummy(or any other user)export DISPLAY=:0(use X11)/tmp/ubuntu.24.04-x64/DungeonSlime(optionally add--test)
-
DotCL compiles the root system by concatenating all source files in the system (in the order defined in the
.asdfile) into a single.concat.lispfile. -
DotCL keeps track of generic functions in an internal C# dictionary (_gfRegistry) to manage CLOS dispatch and compilation. Even after calling fmakunbound, the generic function object remains registered. See:
public static void RemoveGfRegistryEntry(Symbol sym, bool isSetf = false) -
To pass a boolean "False" to a C# function with
dotnet:invoke:- Do I just send in
nil(works if the type can be discerned) - Use
(dotnet:box nil "BOOL")as in(dotnet:invoke an-object "AMethod" (dotnet:box nil "BOOL"))if the type cannot be inferred by the DotCL Compiler/Runtime
- Do I just send in
Avoid hardcoding the exact output binary path (e.g., bin/Debug/net10.0/ubuntu.24.04-x64/)
into scripts, Makefiles, or Lisp code. The path is inherently brittle since it changes based
on OS, target framework, or build configuration (e.g. -c Release).
There are robust ways to resolve this dynamically depending on context:
The .csproj file naturally computes the output directory dynamically. It can be extracted
directly from the MSBuild evaluation engine without actually building the code by
querying the OutputPath property:
BIN_DIR := $(shell dotnet build DungeonSlime.csproj -getProperty:OutputPath)(This needs to be tested: what will the environment be when things are running?
If the project is actually running via make run or make test, it is possible to query the
.NET Core System.AppContext.BaseDirectory property. It will always return the absolute.
path of the directory containing the currently executing host application:
In C#:
string outputDir = System.AppContext.BaseDirectory;In Lisp (via Interop):
(dotnet:static "System.AppContext" "BaseDirectory")Because System.AppContext.BaseDirectory points to the DotCL compiler process' directory
rather than the target project's directory during macro expansion, it cannot natively
resolve the target bin/ path purely through reflection.
It is possible to invoke a nested MSBuild process using uiop:run-program during Lisp
compilation. It is possible for this to fail silently due to MSBuild node-reuse
locks and inherited environment variables. However, this seems to work in tests:
see type-aliases.lisp.
(let* ((out-path
(uiop:run-program '("dotnet" "build" "DungeonSlime.csproj" "-getProperty:OutputPath")
:output :string
:ignore-error-status t))
;; Trim trailing newlines and whitespace
(bin-dir (string-trim '(#\Space #\Tab #\Newline) out-path))
;; Parse and merge the absolute path to the DLL
(dll-path (merge-pathnames (concatenate 'string bin-dir "MonoGame.Framework.dll")
(asdf:system-source-directory "dungeon-slime"))))
...more-code...)A more robust solution is to add a custom MSBuild target to the .csproj file that
writes $(OutDir) to a standard file location (like obj/dotcl-outdir.txt) right before
the DotCL compilation task runs:
In DungeonSlime.csproj:
<Target Name="WriteOutDirForLisp" BeforeTargets="DotclCompileRoot" Condition="'$(DotclProjectAsd)' != ''">
<WriteLinesToFile File="obj/dotcl-outdir.txt" Lines="$(OutDir)" Overwrite="true" />
</Target>In Lisp (eval-when block):
(eval-when (:compile-toplevel)
(let* ((outdir-file (merge-pathnames "obj/dotcl-outdir.txt" (asdf:system-source-directory "dungeon-slime")))
(bin-dir (with-open-file (s outdir-file) (read-line s)))
(dll-path (merge-pathnames (concatenate 'string bin-dir "MonoGame.Framework.dll")
(asdf:system-source-directory "dungeon-slime"))))
(dotnet:load-assembly (namestring dll-path)))
;; Proceed with class registration safely!
(dotnet:static "DotCL.Runtime" "EnsureDotNetTypeClass"
(dotnet:resolve-type "Microsoft.Xna.Framework.Vector2")))When integrating DotCL with MSBuild via Dotcl.targets, the actual execution of the Lisp compiler is
managed by the DotclCompileRoot MSBuild target.
This target is configured with specific Inputs (all the .lisp files in the project and the
.asd file) and Outputs (the compiled .fasl file). This enables MSBuild's native incremental
build caching mechanisms.
When running make build or dotnet build multiple times in a row without making any modifications
to the source files, MSBuild detects that the .fasl output file is already newer than all of the
.lisp input files. When this happens, MSBuild prints a message stating:
Skipping target "DotclCompileRoot" because all output files are up-to-date with respect to the input files.
Because the compilation target is completely skipped, the DotCL compiler process is never launched.
Consequently, any code inside eval-when (:compile-toplevel) blocks (such as format diagnostic prints,
programmatic assembly loads, or macro expansions) will not be executed again, and no compile-time output
will be printed to the console.
When debugging compile-time macros or eval-when logic and want to force the compiler to run so as to
see the outputs, the MSBuild cache must be invalidated by either:
- Making a modification to any
.lispfile or the.asdfile (e.g., adding a space and saving it). - Running
make clean(ordotnet clean) to delete theobj/andbin/directories, forcing MSBuild to rebuild the.faslfrom scratch on the next build.
In DotCL, the build pipeline natively supports automatically bundling dependencies (like Quicklisp systems) during MSBuild execution.
The Streamlined PackageReference Approach (Upgraded to DotCL 0.1.15):
- Transition to PackageReference: The project is decoupled from the local sibling check-out of
the
dotclrepository by utilizing<PackageReference Include="DotCL.Runtime" Version="0.1.15" />instead of a<ProjectReference>. MSBuild automatically imports the custom compilation targets from the package cache. - Dependencies via
build-setup.lisp: The project registers a build initialization script: build-setup.lisp using the<DotclBuildInit>target. During build execution, the package-internal compiler task loads this script, which automatically finds and loads Quicklisp to register ASDF system search hooks at build-time. - Automated Reference Copying: A custom target
CopyReferencesBeforeLispexecutes before Lisp compilation, copying all referenced NuGet assembly DLLs (likeMonoGame.Framework.dll) to the output directory so they can be loaded by the Lisp compiler at compile-time. - Copying the
contribLibrary: The MSBuild project file includes a target to copy the standardcontribLisp package directories (ASDF, UIOP, socket, thread, etc.) from the restored package assets directory$(_DotCLContribDir)into the outputcontrib/subdirectory, enabling the game to resolve built-in systems dynamically (e.g. via(require "asdf")) at runtime. - Loading Macros at Compile-Time: Because Lisp compilation evaluates dependencies strictly to
generate
.faslbundles, it does not automatically load dependency systems into the compiler's active environment. To use macros from a dependency (likeanaphora:awhen), the system must still be loaded during:compile-toplevel:At runtime,(eval-when (:compile-toplevel) (asdf:load-system "anaphora"))
DotclHost.LoadFromManifestloads all FASL dependencies automatically in the order defined bydotcl-deps.txt.
The input management system (input-manager.lisp) was implemented following
the MonoGame Chapter 11 tutorial. See
opencode-implementation-notes.md for
full details of the implementation.
Key points:
- The
:dungeon-slime-inputpackage uses local nicknames to reference the generated C# packages, avoiding symbol conflicts. - Each
coregame instance has oneinput-manager(class allocation). - The update order follows the C# one-frame-latency pattern: game logic
reads input from the previous frame's state update, which happens in
core:updateviacall-next-method. - The generated cspackages must be pre-declared as empty packages in
packages.lisp(alongside the other pre-declares) so the:dungeon-slime-inputpackage's:local-nicknamescan be resolved at package-definition time before the cspackages are loaded.
The tilemap system (tilemap.lisp, tileset.lisp) implements Chapter 13 of the MonoGame tutorial.
Instead of using XML for configuration as suggested in the tutorial, the implementation utilizes
S-expressions for the tilemap-definition.lisp file.
Key points:
- The tilemap relies on the
Content.mgcbfile to copytilemap-definition.lispinto the output directory. safe-read-form-from-fileis used to load the S-expression configuration securely.- S-expression lists specify rows and columns, slicing into a texture atlas's
texture-region. - Room boundaries are computed dynamically based on the tilemap's loaded dimensions and scale, rather than hard-coding the screen bounds, allowing the game physics and collisions (e.g. slime bounding box) to scale naturally with the map.
- The tilemap definition file is located in the Content directory. The constant
+tilemap-filename+combines the default content directory prefix (Content/) so thatqualify-pathcorrectly resolves the path at runtime (both relative to the root and in the C# build output folder). - To prevent type conversion issues, dimensions used in constructing the room
bounds
Rectangle(e.g. scale * size) must be coerced to integers (usinground) before calling therectconstructor, as the C#Rectangleconstructor requiresInt32parameters. - Textures are loaded using
content-load-texture2d, which wraps genericContentManager.Load<Texture2D>calls through DotCL's generic invocation or returns the raw asset name when the manager isnil(for CLI testing context).
The audio system (audio-test.lisp and updates to game-1.lisp) implements
Chapter 14 of the MonoGame tutorial. Instead of loading audio via
ContentManager.Load<T>, the implementation uses direct filesystem loading
via the static methods of SoundEffect (from-file) and Song (from-uri).
In .NET, generic methods are methods that are defined with type parameters. A
common example in MonoGame is ContentManager.Load<T>(string assetName).
In DotCL, you cannot invoke a generic method directly using standard member
invocation because the Lisp runtime needs to know which concrete type signature
to look up in the .NET metadata.
To resolve this, DotCL provides the dotnet:generic-method function:
(dotnet:generic-method "MethodName" "TypeArgument1" "TypeArgument2" ...)For example, to call Content.Load<SoundEffect>("audio/bounce"), you would write:
(dotnet:invoke content-manager
(dotnet:generic-method "Load" "Microsoft.Xna.Framework.Audio.SoundEffect")
"audio/bounce")This instructs DotCL to perform a generic method instantiation for the Load
method on the ContentManager instance, specializing it with the type
Microsoft.Xna.Framework.Audio.SoundEffect before invoking it.
While dotnet:generic-method works, it relies on runtime reflection to resolve
the generic signature. Whenever possible, using non-generic static construction
methods provided by C# classes is preferred as it is cleaner and uses the
pre-generated C# wrappers.
For instance, SoundEffect.FromFile(string path) and
Song.FromUri(string name, Uri uri) are non-generic static methods. They can
be invoked directly through the Lisp package wrappers sound-effect:from-file
and song:from-uri, bypassing ContentManager entirely.
The audio controller system (audio-controller.lisp and updates to game-1.lisp and mg-core.lisp) implements Chapter 15 of the MonoGame tutorial.
Key points:
- The
audio-controllerCLOS class provides centralized audio lifecycle management. - Because
SoundEffectInstanceobjects are limited resources, they must be manually disposed of when they stop playing. The audio controller tracks active instances and cleans them up inupdate-audiowhen they reach theStoppedstate. - Keyboard bindings for global mute (
M) and volume adjustment (+and-) are integrated into the main input polling loop ingame-1.lisp. - The
cspackagesgenerator is updated to include wrappers forSoundEffectInstanceandSoundState.
A significant challenge arose when adjusting the master volume and media volume
via .NET reflection. In C#, the properties SoundEffect.MasterVolume and
MediaPlayer.Volume are defined as C# float (single-precision floating-point
numbers).
When Lisp queries a C# float property using dotnet:static or
dotnet:invoke, the DotCL runtime automatically marshals the return value into
a Lisp DoubleFloat (a double-precision float).
If arithmetic is performed on this value (e.g., adding or subtracting a volume
delta), the result remains a DoubleFloat in Lisp.
When the updated Lisp DoubleFloat value is set back to the C# property using
(setf (dotnet:static ...)), DotCL's underlying binder attempts to find a match.
Specifically:
- DotCL calls the .NET reflection
InvokeMemberAPI withBindingFlags.SetProperty. - Because the Lisp value is marshaled to .NET as a
double(a 64-bit float), the binder searches for a property setter that accepts adouble. - The .NET reflection binder does not automatically downcast or narrow a
doubleto afloat(a 32-bit float) for property setters. - Consequently, the member lookup fails completely, throwing a
System.MissingMethodExceptionwith a message indicating the property setter could not be found.
To resolve this reflection binding mismatch, values must be explicitly coerced
back to Lisp's single-float type using (coerce value 'single-float) before
passing them to the C# property setter. This ensures DotCL marshals the value
as a 32-bit float, matching the property signature in the assembly.
Example:
(defun set-master-volume (vol)
(setf (dotnet:static "Microsoft.Xna.Framework.Audio.SoundEffect" "MasterVolume")
(coerce vol 'single-float)))SpriteFont is a sealed C# class from MonoGame with no virtual methods.
Creating a CLOS wrapper class around it would add unnecessary indirection
without providing any polymorphic benefit. Instead, sprite-font.lisp
provides three helper functions:
load-font— loads a SpriteFont viaContentManager.Load<SpriteFont>(name)measure-string— callsSpriteFont.MeasureString(string)to get text dimensionsdraw-string— callsSpriteBatch.DrawString(...)with keyword arguments
This follows the same pattern used for SoundEffect and Song helpers in the
generated cspackages/ directory.
The SpriteFont must go through the MonoGame content pipeline (not a simple copy)
to be processed by the FontDescriptionImporter and FontDescriptionProcessor.
The Content.mgcb entry uses these processors with the font size, kerning,
and character region settings matching the .spritefont XML description.
The font file 04B_30.ttf is referenced by name in the .spritefont file.
Note: The MonoGame tutorial's download link for this font (docs.monogame.net)
returns a 404 error. Rather than using the temporary AdwaitaMono system font fallback,
the correct original 04B_30.ttf pixel font has been copied from the user's Downloads
folder. Credit and license details for this font can be found on its
FontSpace Page.
The score text origin is computed by measuring the string "Score: 0" (not
just "Score") to ensure proper left-center alignment. This is because the
full text "Score: 123" or "Score: 9999" will be drawn at render time,
and the origin needs to account for the full width of the text pattern.
The position is computed once during initialize (after load-content via
call-next-method) and stored in the score-text-position and
score-text-origin slots on the game-1 class.
packages -> mg-classes -> clr-generic -> sprite-font -> game-1
sprite-font.lisp depends on mg-classes for v2:+zero+, v2:+one+
and sprite-effects:+none+. It depends on clr-generic for the
dotnet-class require.
All .lisp files have been reviewed and raw .NET interop calls (dotnet:new,
dotnet:invoke, dotnet:static, and raw namespace-qualified readers like
#!!) were replaced with their corresponding cspackages wrappers.
To allow packages like dungeon-slime-input and mg-classes to use generated C# package
wrappers via local nicknames, all generated C# package namespaces were moved to the
top of packages.lisp and pre-declared empty:
(defpackage :microsoft-xna-framework-game-time)(defpackage :microsoft-xna-framework-game)(defpackage :microsoft-xna-framework-graphics-sprite-batch)(defpackage :microsoft-xna-framework-input-mouse)This ensures compilation and package loading succeed without order-of-definition crashes.
In input-manager.lisp, raw constructor and static state query calls were replaced:
(dotnet:new "Microsoft.Xna.Framework.Input.KeyboardState")->(kb-state:new)(#!!Microsoft.Xna.Framework.Input.Keyboard.GetState)->(kb:get-state)(dotnet:new "Microsoft.Xna.Framework.Input.MouseState")->(ms:new)(#!!Microsoft.Xna.Framework.Input.Mouse.GetState)->(mouse:get-state)(dotnet:static "Microsoft.Xna.Framework.Input.Mouse" "SetPosition" ...)->(mouse:set-position ...)(dotnet:new "Microsoft.Xna.Framework.Input.MouseState" ...)->(ms:new ...)(dotnet:new "Microsoft.Xna.Framework.Input.GamePadState")->(gp-state:new)(#!!Microsoft.Xna.Framework.Input.GamePad.GetState ...)->(gp:get-state ...)(dotnet:static "Microsoft.Xna.Framework.Input.GamePad" "SetVibration" ...)->(gp:set-vibration ...)
In audio-controller.lisp, instance disposal calls were refactored:
(dotnet:invoke instance "Dispose")->(sei:dispose instance)
In audio-test.lisp, raw system URI constructors were refactored:
(dotnet:new "System.Uri" ...)->(system-uri:new theme-path system-uri-kind:+relative+)
In mg-classes.lisp, raw Vector2 static field fetches, Rectangle constructors, and GameTime
property gets were refactored:
(dotnet:invoke gt "TotalGameTime")->(game-time:total-game-time gt)(dotnet:invoke gt "ElapsedGameTime")->(game-time:elapsed-game-time gt)(dotnet:invoke gt "IsRunningSlowly")->(game-time:is-running-slowly gt)(dotnet:static "Microsoft.Xna.Framework.Vector2" "Zero")->v2:+zero+(dotnet:static "Microsoft.Xna.Framework.Vector2" "One")->v2:+one+(dotnet:static "Microsoft.Xna.Framework.Vector2" "UnitX")->v2:+unit-x+(dotnet:static "Microsoft.Xna.Framework.Vector2" "UnitY")->v2:+unit-y+(dotnet:new "Microsoft.Xna.Framework.Rectangle" ...)->(rect:new x y w h)
In mg-core.lisp, C# window/game property accesses and the SpriteBatch constructor
were refactored:
(dotnet:invoke mg "Content")->(game:content mg)(dotnet:invoke mg "Window")->(game:window mg)(setf (dotnet:invoke mg "IsMouseVisible") T)->(setf (game:is-mouse-visible mg) T)(dotnet:invoke monogame "GraphicsDevice")->(game:graphics-device monogame)(dotnet:new "Microsoft.Xna.Framework.Graphics.SpriteBatch" ...)->(sprite-batch:new base-gd)
In dungeon-slime.asd, components that utilize the local C# package nicknames (mg-classes and
input-manager) were updated to explicitly list the generated *cspackages-components* in their
:depends-on declaration list. This ensures the generator stubs are fully loaded
and defined before the main game files compile.
Under DotCL 0.1.14, executing ASDF operations (such as compiling or loading the
dungeon-slime system or Quicklisp packages) may generate various warnings
and compile-failed conditions. These warnings have been analyzed and verified
as completely benign.
- Cause: This warning is signaled by ASDF when a nested
load-systemorrequireoperation is initiated while another ASDF operation is already active on the same image. - Occurrences:
- In
load-system-test.lispduring(eval-when (:compile-toplevel) (asdf:load-system "anaphora"))while compilingdungeon-slime. - In
game-repl.lispduring(eval-when (:compile-toplevel :load-toplevel :execute) (require "dotcl-repl"))which translates to(asdf:load-system "dotcl-repl")while compiling/loadingdungeon-slime.
- In
- Impact: Completely benign. ASDF handles the nested operation correctly
and loads the systems. However, because
RECURSIVE-OPERATEis a standard warning (not astyle-warning), it triggers the compiler's warning check, which ASDF reports asCOMPILE-WARNED-WARNINGandCOMPILE-FAILED-WARNINGfor the compiling file.
- Cause: In
quicklisp/http.lisp, a generic functionemptypis defined via(defgeneric emptyp ...). When its FASL assembly is loaded, the DotCL cross-package function bridge copy-on-intern logic inherits the function slot of the existing ordinary functionuiop:emptyp(which is in theUIOPpackage) ontoql-http::emptyp. As a result, thedefgenericredefinition check sees that the symbolql-http::emptypis alreadyfboundpas a non-generic function and issues a warning:WARNING: DEFGENERIC: EMPTYP is being redefined as a generic function, but it was previously defined as an ordinary function. - Impact: Completely benign. The warning handler automatically clears the
inherited function slot using
fmakunboundand correctly binds the generic function. This warning only occurs during compilation (when ASDF loads the freshly built FASL) and when first loading Quicklisp setup, and does not affect the game.
The C# Generic Function type checking in clr-generic.lisp was updated from manual reflection-based IsAssignableFrom checks to use dotnet:is-instance-of which was introduced in DotCL 0.1.14.
In c#method-applicable-p (defined in clr-generic.lisp), the previous implementation looked up types manually and invoked reflection:
(let ((arg-type (monoutils:get-type first-arg))
(spec-type (monoutils:get-type spec)))
(when (and arg-type spec-type)
(dotnet:invoke spec-type "IsAssignableFrom" arg-type)))This was refactored to use the native type predicate:
(dotnet:is-instance-of first-arg spec)This eliminates manual type-lookup reflection logic in Lisp and delegates it to the DotCL runtime.
When upgrading to DotCL 0.1.14 and performing make build, the compilation crashed with a PACKAGE-ERROR stating that no package named "ANAPHORA" exists.
- Reader/Package Mismatch: During compilation, the DotCL compiler concatenates all files into
.concat.lispand reads them completely before evaluation. The Lisp reader parses symbols likeanaphora:awhenbefore theeval-whenform that loads"anaphora"is evaluated. - Pre-declaration Stub: To prevent reader package lookup errors, a stub
(defpackage :anaphora)was added topackages.lisp(which is compiled first), following the established repository pattern for C# package stubs. - Stale FASL Cleanup: Stale
.faslfiles compiled by previous DotCL versions (such as 0.1.11 or 0.1.12) are incompatible with DotCL 0.1.14 and are silently ignored by the loader during the dependency resolution phase. Consequently, the actual"anaphora"package was never loaded. Deleting all stale.faslfiles in both the workspace and the Quicklisp directory and cleaning the project'sobjdirectory forces a full rebuild, compiling the dependencies cleanly using DotCL 0.1.14.
The build dependency hierarchy was refactored to eliminate compile-time
circular dependencies, simplify dungeon-slime.asd, and streamline the REPL
loader (load-repl.lisp).
Previously, utils.lisp relied on the generated C# wrapper package
system-app-domain to retrieve +base-directory+ via
(app-domain:base-directory app-domain:current-domain). However, the generator
for system-app-domain depended on utils.lisp. This created a compile-time
and load-time dependency loop.
To break this loop:
- The initialization of
+base-directory+inutils.lispwas rewritten to use direct, native.NETinterop calls:(defconstant +base-directory+ (dotnet:invoke (dotnet:static "System.AppDomain" "CurrentDomain") "get_BaseDirectory") "Get the C# base directory of this current executable")
- This decouples the utility layer entirely from
system-app-domain.lisp. - Stub definitions for
:system-app-domain,:system-object, and:system-typeinpackages.lispwere reverted to simple stubs. Stub exports are only retained for symbols referenced at read-time by referencing files before the generated packages compile (system-object:get-typeandsystem-type:full-name).
With utility modules decoupled from generated code, the component load order in
dungeon-slime.asd was restored to a simple, flat linear sequence:
packagessettingstype-aliasesmonoutilsutils*cspackages-components*(loads all generated package wrappers, all of which now consistently depend onpackages,utils, andmonoutils)- Remaining game files.
This completely eliminated complex remove-if and remove-if-not filters
from the system definition.
To ensure the compiled game executable runs cleanly without requiring ASDF to
be loaded at runtime, all system directory and assembly paths inside
type-aliases.lisp are resolved dynamically by checking if ASDF is present
via (find-package :asdf).
If ASDF is present (during Lisp compilation or inside REPL sessions), the
loader resolves the output directory relative to the ASDF system directory and
loads the assemblies dynamically. If ASDF is absent (during standalone
executable execution), the assembly loading sequence is completely skipped,
as the assemblies are already statically linked and loaded by the C# host
process. This prevents runtime Undefined function errors.
The C# helper registrar MonoUtilsRegistrar.Initialize call was moved to a
load-time eval-when block at the end of monoutils.lisp.
Because assembly loading, path resolution, and registrar initialization are
now handled automatically by the ASDF system, load-repl.lisp was simplified
to just:
- Load ASDF and register the directory.
- Load the
"dungeon-slime"ASDF system. - Change package to
:dungeon-slime. - Create the game object and configure
RootDirectoryusing*content-directory*.
To clean up references to the system-time-span package, all references across
the codebase were migrated to use a :ts package local nickname:
- Package definitions for
:dungeon-slime,:dungeon-slime-input, and:dungeon-slime-testsin packages.lisp were updated to include(:ts :system-time-span)under:local-nicknames. - The obsolete
:time-spannickname was removed to prevent collisions. - All code symbols (e.g.
system-time-span:from-milliseconds) and tests (e.g.system-time-span:from-ticks) were refactored to usets:. - Test code using dynamic symbol reflection (e.g.,
(find-symbol "=" :ts)) was updated to make direct, read-time resolved calls (e.g.(ts:= t1 t2)), eliminating runtime package name resolution issues.
To support multi-user testing environments (e.g., executing the game compiled by
one user under another local account via /tmp or running from different working
directories), the codebase implements path qualification safety and graceful host
subsystem failure fallbacks.
By default, the MonoGame Content Builder (MGCB) compiles audio files into
.xnb format. However, the Lisp game engine loads sound effects directly from
the filesystem using SoundEffect.FromFile, bypassing the content pipeline.
To support this, a copy target is declared in DungeonSlime.csproj to
explicitly copy raw .wav sound files under Content/audio/ to the build output
directory, keeping them alongside the compiled assets.
Relative paths passed to filesystem-based static constructors (e.g.,
SoundEffect.FromFile) are resolved to the application's base installation directory
using qualify-path (defined in utils.lisp).
- Type Safety: Lisp path-manipulation functions (such as
uiop:subpathname*insidepath-combine) returnpathnameobjects. Passing apathnameobject to a C# method expecting a string signature results in a method lookup failure (MissingMethodException). - Fix:
qualify-pathis updated to convert resolved pathnames to native string representations usinguiop:native-namestring.
Theme songs are loaded via Song.FromUri. When loading qualified filesystem paths on Unix
systems (which lack a scheme prefix like file:///), the constructor is called using
system-uri-kind:+relative-or-absolute+:
(system-uri:new qualified-path system-uri-kind:+relative-or-absolute+)This prevents UriFormatException crashes while correctly resolving both absolute paths (when
the file exists in the installation directory) and relative fallbacks.
Under multi-user environments, access to the primary user's sound server (PipeWire or PulseAudio) is frequently restricted, causing OpenAL device initialization to fail.
- Tolerant Loading: Audio resource loading in game-1.lisp is wrapped in a
handler-caseexpression. If sound device initialization fails, a warning is printed to the error console, the audio slots are set tonil, and the game successfully loads and runs in silent mode rather than crashing. - Protected Actions: Playback controls and volume setters/getters in
audio-controller.lisp are wrapped in
handler-caseand guard againstnilinputs, ensuring volume adjustments or collision triggers do not crash during silent runs.
Starting with generator v21, dotcl-packagegen (../package-generator) emits
its own ASDF system definition, cspackages/csharp-assembly-packages.asd,
alongside the generated .lisp files. By v23 that generated system is fully
self-contained (its own packages.lisp and csharp-assembly-utils.lisp, no
dependency on anything in this project). Before this change, dungeon-slime.asd
had no such file to build against, so it worked around the gap with an
eval-when-computed *cspackages-components* list that raw-scanned the
cspackages/ directory for *.lisp files and spliced each one into
dungeon-slime's own :components, guessing :depends-on ("packages" "utils" "monoutils") for every one of them (an assumption that was already wrong by
v23 — the generated files don't reference this project's utils/monoutils
at all, they depend on the generator's own packages and
csharp-assembly-utils). This is the change referenced in the commit
"Use package generator v23 ... Next: update the .asd."
The obvious "correct" fix looks like turning csharp-assembly-packages into a
real ASDF system dependency:
(defsystem "dungeon-slime"
:depends-on ("csharp-assembly-packages" "dotnet-class" "dotcl-thread" "dotcl-repl" "anaphora")
:components (...))with cspackages/ registered on asdf:*central-registry* (the same idiom
load-repl.lisp already uses for the project root) so ASDF can find
csharp-assembly-packages.asd by name. This reads cleanly, drops the
:depends-on splicing needed on every consumer component (mg-classes,
poc-test, input-manager, audio-controller, cspackages-test,
typed-calls-test), and is exactly what you'd do for a normal Quicklisp-style
library dependency.
It does not work under the DotCL/MSBuild build pipeline used by this
project (DotCL.Runtime.ProjectCore.targets, DotCL.Runtime 0.1.15+). That
pipeline compiles a project's Lisp system in two separate MSBuild targets,
run in this order:
_DotCLResolveDeps— runs theDotclResolveDepsMSBuild task, which walks the target.asd's:depends-ongraph and compiles each dependency into its own standalone.fasl(viaDotCL.Build.Tasks.dll, in-process, using only theDotCL.Runtime.dll+dotcl.corebase image plus whatever Quicklisp systemsbuild-setup.lispregisters). The resulting per-dependency FASLs get bundled by the subsequent_DotCLBundleDepstarget._DotCLCompileRoot— runs theDotclCompileProjectMSBuild task, which compiles the root system's own:components(i.e., the actual.lispfiles listed directly underdungeon-slime's:components, not under its dependencies) into a singleDungeonSlime.fasl.
Critically, only _DotCLCompileRoot loads the consuming project's own .NET
assembly references (MonoGame.Framework.dll etc.) into the Lisp runtime's
type-resolution context before compiling. _DotCLResolveDeps does not — it's
built for portable, assembly-agnostic Lisp libraries (the kind you'd pull
from Quicklisp), and has no notion of "this specific game's referenced NuGet
assemblies."
The generated cspackages/*.lisp files, however, call dotnet:resolve-type
directly at the top level, e.g. (from cspackages/microsoft-xna-framework-vector2.lisp):
(cl:defconstant <type> (dotnet:resolve-type "Microsoft.Xna.Framework.Vector2"))This runs at load time (and inside an eval-when for EnsureDotNetTypeClass
right below it), so it needs Microsoft.Xna.Framework.Vector2 to already be a
resolvable .NET type — i.e., MonoGame.Framework.dll must already be loaded
into the process. When csharp-assembly-packages is a :depends-on of
dungeon-slime, ASDF/DotCL compiles it during _DotCLResolveDeps, before
that assembly is loaded, and the build fails hard:
error : dotcl resolve-deps failed for .../dungeon-slime.asd:
DOTNET: type not found: Microsoft.Xna.Framework.Vector2
This was confirmed experimentally (not just inferred from reading the
targets file): switching to the :depends-on form above reproduced this
exact error during _DotCLResolveDeps, on a project that otherwise built
fine before generator v21 introduced the possibility of doing this.
Interesting near-miss: simply moving the generated files' splice
position earlier in dungeon-slime's own :components list (still as
plain components of the root system, not a :depends-on) reproduces the
same DOTNET: type not found error, but this time inside
_DotCLCompileRoot instead of _DotCLResolveDeps. That's because
_DotCLCompileRoot compiles the root's components strictly in a topologically
valid order derived from their :depends-on edges (not necessarily the
literal list order), and type-aliases.lisp — the file in this project that
actually loads MonoGame.Framework.dll (see type-aliases.lisp's "Loading
assembly" logging) — must be compiled, and must run its eval-when assembly
load, strictly before any cspackages file that resolves a MonoGame type. So
the constraint isn't "cspackages must be a same-system component instead of a
system dependency" in isolation — it's specifically "cspackages must be
ordered, with an explicit ASDF dependency edge, after type-aliases", full
stop, regardless of which mechanism is used to include the files. A
:depends-on system dependency can never satisfy that, because the whole
dependency graph is resolved and compiled as a unit before the root
system's own type-aliases.lisp gets anywhere near running.
dungeon-slime.asd keeps the "splice generated files into the root system's
own :components" shape (so they compile inside _DotCLCompileRoot, which
does have the assembly loaded), but instead of directory-scanning
cspackages/ and guessing dependency names, it now reads
cspackages/csharp-assembly-packages.asd itself (with *read-eval* nil) and
pulls the real :components form back out of it:
(eval-when (:compile-toplevel :load-toplevel :execute)
(defparameter *cspackages-components*
(let* (...
(cspackages-asd (uiop:subpathname cspackages-dir "csharp-assembly-packages.asd")))
(if (uiop:file-exists-p cspackages-asd)
(let* ((*read-eval* nil)
(defsystem-form (with-open-file (s cspackages-asd) (read s)))
(generated-components (getf (cddr defsystem-form) :components)))
(mapcar (lambda (comp)
(destructuring-bind (kind name &key depends-on) comp
(list kind (concatenate 'string "cspackages/" name)
:pathname (uiop:subpathname cspackages-dir (concatenate 'string name ".lisp"))
:depends-on (cons "type-aliases"
(mapcar (lambda (d) (concatenate 'string "cspackages/" d))
depends-on)))))
generated-components))
nil))))Each generated component name is prefixed with "cspackages/" (so
"packages" and "csharp-assembly-utils" can't collide with this project's
own packages.lisp/utils.lisp components), its :pathname is pointed at
the real file under cspackages/, and its :depends-on is rewritten to use
the same "cspackages/"-prefixed names for its declared dependencies (so
system-console's real dependency on packages and csharp-assembly-utils
becomes a dependency on cspackages/packages and
cspackages/csharp-assembly-utils — the actual generator-declared graph, not
a guess), plus an explicit extra dependency on "type-aliases" so every
spliced file is guaranteed to compile after the MonoGame assembly is loaded.
This list is spliced into dungeon-slime's :components in the same
position the old scan-based version used (after packages, settings,
type-aliases, monoutils, utils; before constants and everything
else), and every consumer component that references cspackages symbols
(mg-classes, poc-test, typed-calls-test, input-manager,
audio-controller, cspackages-test) still splices in
(mapcar (lambda (comp) (second comp)) *cspackages-components*) as extra
:depends-on entries, exactly as before — that part of the old mechanism was
correct and is retained.
While investigating this, the block of ~40 (defpackage :microsoft-xna-framework-vector2)-style empty package stubs at the top of
packages.lisp looked like leftover cruft from the old, unordered directory
scan — the comment above them literally says "so local-nicknames doesn't
crash." It's tempting to delete them once the splice above gives cspackages
files a real, ordered dependency graph. Don't — they're independently
load-bearing, for a different reason:
packages.lisp(this project's own, not the generated one) is the very first component indungeon-slime's:components, and it defines:local-nicknames(e.g.(:v2 :microsoft-xna-framework-vector2)) for packages like:mg-classesand:dungeon-slime— those local-nicknames need the target package to already exist as an object whenpackages.lispis compiled, even though none of its symbols need to be bound or exported yet.type-aliases.lisp(which loadsMonoGame.Framework.dll):depends-on ("packages" "settings")— it must run after this project's ownpackages.lisp.- The real
cspackages/packages.lisp(which defines those same package objects with their full, generator-produced:exportlists) can only compile aftertype-aliases.lisphas loaded the assembly (per the constraint in section 1).
That's a cycle if packages.lisp needed the real cspackages packages to
exist: packages.lisp → (needed by) type-aliases.lisp → (needed by)
cspackages/packages.lisp → (needed by, for real exports) packages.lisp.
The stub (defpackage :microsoft-xna-framework-vector2) forms break the
cycle: they give packages.lisp's local-nicknames a package object to point
at immediately, and later, once cspackages/packages.lisp actually compiles,
Common Lisp's defpackage semantics let it reopen and extend that same
package object with its real :export list — a package can be defpackage'd
more than once, and later :export clauses add to what's already there
rather than replacing it, as long as nothing conflicts.
Two related stub packages, :system-object and :system-type, additionally
carry a couple of explicit :export forms (get-type, full-name) in the
pre-declaration block. These predate generator v23 (which now exports both
symbols directly from the real generated packages), so they're redundant
today, but harmless — re-exporting an already-exported symbol is a no-op —
and were left as-is to keep this change minimal.
While tracing exactly which package each generated file's error-handling code
referenced, utils.lisp turned out to define its own
(cl:define-condition csharp-overload-not-found (cl:error) ...), exported
from the :utils package. But cspackages/csharp-assembly-utils.lisp (the
generator's own shared runtime support file, self-contained since v23)
defines an identically-named-but-different condition class,
csharp-assembly-utils:csharp-overload-not-found, and every generated
wrapper's overload dispatcher signals that one:
;; cspackages/microsoft-xna-framework-vector2.lisp
(cl:t (cl:error 'csharp-assembly-utils:csharp-overload-not-found ...))cspackages-test.lisp's overload-resolution tests, however, were written
against the older utils:csharp-overload-not-found:
(handler-case
(progn
(v2:/ (v2:new 1.0e0 2.0e0) "invalid-value")
(error "..."))
(utils:csharp-overload-not-found (e) ...)) ; <- wrong condition class!Since utils:csharp-overload-not-found and
csharp-assembly-utils:csharp-overload-not-found are two distinct,
unrelated condition classes (both directly subclass cl:error; neither
inherits from the other), this handler-case clause could never actually
catch what v2:/ signals. In practice this meant the (error "...") inside
the handler-case body would itself propagate uncaught up through the whole
handler-case form (since the handler clause never matches), rather than
the intended assertions on the condition's slots ever running.
Fix: deleted the dead-code duplicate condition definition from
utils.lisp (and its now-pointless :export entries from the :utils
package in packages.lisp), and updated cspackages-test.lisp to catch
make test: the "Condition class name" / "Condition method name" /
"Condition package name" / "Condition supplied-args ..." assertions inside
both overload-resolution test blocks (Vector2 / and sprite-batch:begin)
now report PASS, confirming the handler is actually exercised.
find . -name "*.lisp" -o -name "*.asd" | xargs python3 check_parens.py(make check-parens) on all touched files.dotnet build DungeonSlime.csproj -v d -c Debug(make build) — succeeds cleanly, 0 warnings, 0 errors.make test— fulltest-harness.lisprun passes, including the newly-exercised overload-condition assertions above.make replequivalent (dotcl --eval '(load "load-repl.lisp")' ...) — loads the whole system, boots agame-1instance, and exposes*mg-game*with no package or condition errors.
Rather than using C# Vector2 mutation for tracking background offsets which would allocate on
the heap or require custom interop every frame, the offsets are tracked as simple Lisp float
slots (background-offset-x and background-offset-y) on the title-scene class.
The scrolling offset is updated in the update method by multiplying the scroll speed (120
pixels/sec) by delta time. Wrapping is implemented using the standard Common Lisp mod function
against the background texture's width and height. This leverages Lisp's built-in mathematical
semantics to keep offsets bounded cleanly.
MonoGame requires a separate SpriteBatch.Begin and SpriteBatch.End block when rendering with
different SamplerState values.
In title-scene:draw, the rendering is split into two sequential passes:
- Tiling Background: Rendered with
sampler-state:+point-wrap+. A destination rectangle is created covering the entire screen window size. A source rectangle of the same size starting at the rounded offsets(ox, oy)is passed. The GPU handles repeating/wrapping the texture automatically. - Foreground UI and Text: Rendered with
sampler-state:+point-clamp+to keep the title screen fonts and prompt text crisp without tiling.
To align with the project's strict architecture guidelines, all direct dotnet:invoke and
dotnet:static calls have been eliminated from title-scene.lisp by importing and leveraging
the generated type wrappers from cspackages/:
cm:loadis used to load the texture asset.gd:clearclears the screen.sprite-font:line-spacingdetermines line offsets.ts:total-secondsandgame-time:total-game-time/elapsed-game-timehandle timing.sprite-batch:endcloses rendering passes.game:exithandles clean shutdown. To supportsprite-font:line-spacingwithout namespaces, the(:sprite-font :microsoft-xna-framework-graphics-sprite-font)local nickname was added to the:dungeon-slimepackage definition inpackages.lisp.
To support high-resolution displays (such as the default 1280x720 window size defined in
constants.lisp), the title-scene:draw method does not use static size config values from
window-info (which would fallback to 800x480 when window-info is unbound). Instead, it
queries the active GraphicsDevice viewport directly via (gd:viewport gd).
By passing the viewport object to the generic width and height functions, the actual
rendering backbuffer resolution is retrieved dynamically. These bounds are then used for:
- Sizing the destination and source rectangles to ensure the tiled background covers the entire window space.
- Correctly calculating the horizontal centering (
win-w) of all title lines and prompt text. - Scaling the vertical offsets (
start-yandpos-y) proportionally relative towin-h(using15%and80%height factors respectively) to maintain a consistent UI layout at any window resolution.