Compare commits
87 Commits
8cfa8ddfeb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f6839bcc4 | |||
| d37109f5f7 | |||
| 15bd9fc43c | |||
| 8049e90853 | |||
| e47a2c3e5c | |||
| 9512c22e1f | |||
| 8a77001016 | |||
| 9b0214ee55 | |||
| 7ad735552a | |||
| ea35472ef8 | |||
| 50c5621d8a | |||
| da275cfd52 | |||
| 0f4ee5d965 | |||
| af8c0f5ccd | |||
| 8ea370a1d1 | |||
| fcf0de72af | |||
| 320c5e6ce5 | |||
| 7a858cc424 | |||
| c0292842a5 | |||
| 717902462b | |||
| 1d73b9d224 | |||
| 51f262efa0 | |||
| a6e4e3f71f | |||
| 4387d223b9 | |||
| 00bfaf6360 | |||
| 709bd7be52 | |||
| 80f4348e21 | |||
| e630827b34 | |||
| 106d9b0fc0 | |||
| 4a26f79945 | |||
| 1a199f6ce3 | |||
| ec59e77867 | |||
| cb28d2b611 | |||
| 27b6ddf5cb | |||
| 475c865e33 | |||
| 0b4ba062bb | |||
| 28f5e66662 | |||
| e225a076f0 | |||
| 774c8ad0f8 | |||
| 52d1e7414d | |||
| af4cb53e5f | |||
| c019271e12 | |||
| 82ae2bce9d | |||
| 092e259a06 | |||
| 08b4bbfe91 | |||
| 560c51cf27 | |||
| 674f86b18a | |||
| e3f10e0926 | |||
| d7223d7387 | |||
| 45331c2a60 | |||
| 33f50a2c69 | |||
| 6c8e4d5cbd | |||
| 3b7215876a | |||
| c74f5890bd | |||
| fbd3c71ba7 | |||
| 128f9ab9d4 | |||
| 36fb359aa2 | |||
| 1bd73d69fe | |||
| fb48285143 | |||
| 3de50b8370 | |||
| e61cbe25b7 | |||
| 8395830be6 | |||
| 6e4ec2b9d8 | |||
| f501bb8e28 | |||
| d07cd3397d | |||
| e008fb108a | |||
| 9aaffff7a8 | |||
| 7357b4a5df | |||
| 1ddc298a74 | |||
| e2a9442aa6 | |||
| aa0180571e | |||
| 7f7be39230 | |||
| 4d95415232 | |||
| 2cbd80a004 | |||
| 9abf8101da | |||
| 24badd06a5 | |||
| 7a03ef8eaf | |||
| f3ea507313 | |||
| 4b0388a0e1 | |||
| a84137b5ff | |||
| ca02ee0352 | |||
| fbaa54145e | |||
| 28754ffbf2 | |||
| 470c0eba7a | |||
| 7098dcec43 | |||
| 07137f57af | |||
| 8b7491a3d3 |
@@ -1,455 +0,0 @@
|
||||
# Dusk — Claude Code rules
|
||||
|
||||
## File headers
|
||||
Every C, H, and JS file starts with:
|
||||
|
||||
```c
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
```
|
||||
|
||||
JS files use `//` comment style instead.
|
||||
|
||||
---
|
||||
|
||||
## C conventions
|
||||
|
||||
### Types
|
||||
Always use the project-defined aliases instead of bare C primitives:
|
||||
|
||||
| Use | Not |
|
||||
|-----------|--------------|
|
||||
| `bool_t` | `bool` |
|
||||
| `int_t` | `int` |
|
||||
| `float_t` | `float` |
|
||||
| `char_t` | `char` |
|
||||
|
||||
Use `uint8_t`, `uint16_t`, `int32_t`, etc. for fixed-width integers.
|
||||
All struct and enum types end in `_t` (`animation_t`, `errorret_t`, …).
|
||||
|
||||
### Naming
|
||||
- **Functions** — snake_case, prefixed with their module:
|
||||
`assetLock()`, `entityPositionInit()`, `moduleAssetBatchCtor()`
|
||||
- **Struct fields** — camelCase: `keyframeCount`, `localPosition`
|
||||
- **Macros / constants** — UPPER_SNAKE_CASE:
|
||||
`ENTITY_ID_INVALID`, `ERROR_OK`, `COMPONENT_TYPE_COUNT`
|
||||
- **Files** — snake_case matching the primary type: `entityposition.c`,
|
||||
`moduleassetbatch.c`
|
||||
|
||||
### Header files (`.h`)
|
||||
- Use `#pragma once` — no include guards.
|
||||
- Declare every public function, `#define`, and `extern` global.
|
||||
- Write a JSDoc block (`/** … */`) above every declaration explaining
|
||||
purpose, `@param`s, and `@returns`.
|
||||
- Only include headers that the `.h` file itself strictly requires for
|
||||
the types it exposes. Move everything else to the `.c` file.
|
||||
Do not use forward declarations as a workaround — use the real
|
||||
include in the `.c` file instead.
|
||||
|
||||
### Implementation files (`.c`)
|
||||
- Contain function bodies only; no declarations.
|
||||
- Pull in whatever additional includes the implementation needs.
|
||||
- Do not use `static` or `inline` on **functions**. Every function,
|
||||
including internal helpers, must be declared in the matching `.h` and
|
||||
defined in the `.c` file. Internal helpers belong near the bottom of
|
||||
the `.c` file, not at the top with a `static` qualifier.
|
||||
`static` and `inline` on functions are only appropriate when the
|
||||
function body is written directly inside a `.h` file.
|
||||
`static` on **variables** (file-scope state) is fine and expected.
|
||||
|
||||
### Formatting
|
||||
- Hard-wrap all lines at **80 characters**.
|
||||
|
||||
### Error handling
|
||||
Return `errorret_t` from fallible functions. Use these macros:
|
||||
|
||||
```c
|
||||
errorOk(); // return success
|
||||
errorThrow("msg %d", val); // return failure with message
|
||||
errorChain(someCall()); // propagate failure, continue on success
|
||||
errorIsOk(ret) / errorIsNotOk(ret) // test a result
|
||||
errorCatch(ret); // handle + free an error
|
||||
```
|
||||
|
||||
Never return raw error codes or use `errno` for in-engine errors.
|
||||
|
||||
### Memory
|
||||
Use the project allocator — never raw `malloc`/`free`:
|
||||
|
||||
```c
|
||||
memoryAllocate(size) // allocate
|
||||
memoryFree(ptr) // free
|
||||
memoryZero(dest, size) // zero a block
|
||||
memoryCopy(dest, src, size) // copy
|
||||
```
|
||||
|
||||
### Asserts
|
||||
Prefer specific assert macros over bare `assert()`:
|
||||
|
||||
```c
|
||||
assertNotNull(ptr, "msg");
|
||||
assertTrue(cond, "msg");
|
||||
assertFalse(cond, "msg");
|
||||
assertUnreachable("msg");
|
||||
assertIsMainThread("msg");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build system
|
||||
Each subdirectory has its own `CMakeLists.txt` that adds sources with:
|
||||
|
||||
```cmake
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
myfile.c
|
||||
)
|
||||
```
|
||||
|
||||
Never add source files to the root `CMakeLists.txt` directly.
|
||||
|
||||
---
|
||||
|
||||
## Platform support
|
||||
|
||||
### Targets
|
||||
Set `DUSK_TARGET_SYSTEM` at CMake configure time to select a platform:
|
||||
|
||||
| `DUSK_TARGET_SYSTEM` | Macro defined | Platform |
|
||||
|----------------------|-------------------|------------------|
|
||||
| `linux` | `DUSK_LINUX` | Linux desktop |
|
||||
| `knulli` | `DUSK_KNULLI` | Knulli (handheld)|
|
||||
| `psp` | `DUSK_PSP` | Sony PSP |
|
||||
| `vita` | `DUSK_VITA` | PlayStation Vita |
|
||||
| `gamecube` | `DUSK_GAMECUBE` | Nintendo GameCube|
|
||||
| `wii` | `DUSK_WII` | Nintendo Wii |
|
||||
|
||||
### Layer structure
|
||||
```
|
||||
src/dusk/ core, platform-agnostic game logic
|
||||
src/duskgl/ OpenGL abstraction (Linux, Knulli, PSP, Vita)
|
||||
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP, Vita)
|
||||
src/dusklinux/ Linux + Knulli platform impl
|
||||
src/duskpsp/ PSP platform impl
|
||||
src/duskvita/ Vita platform impl
|
||||
src/duskdolphin/ GameCube / Wii platform impl (no SDL2/OpenGL)
|
||||
```
|
||||
|
||||
Dolphin is the only target that bypasses SDL2 and OpenGL entirely —
|
||||
it uses native GameCube/Wii rendering and input APIs.
|
||||
|
||||
### Platform guards
|
||||
Use the compile-time macros for platform-specific code:
|
||||
|
||||
```c
|
||||
#ifdef DUSK_PSP
|
||||
// PSP-only path
|
||||
#elif defined(DUSK_GAMECUBE) || defined(DUSK_WII)
|
||||
// GameCube / Wii path
|
||||
#else
|
||||
// Generic / Linux fallback
|
||||
#endif
|
||||
```
|
||||
|
||||
Additional capability macros set per-target:
|
||||
`DUSK_SDL2`, `DUSK_OPENGL`, `DUSK_OPENGL_ES`, `DUSK_OPENGL_LEGACY`,
|
||||
`DUSK_INPUT_GAMEPAD`, `DUSK_INPUT_KEYBOARD`, `DUSK_INPUT_POINTER`,
|
||||
`DUSK_PLATFORM_ENDIAN_BIG` / `DUSK_PLATFORM_ENDIAN_LITTLE`.
|
||||
|
||||
### Abstraction pattern
|
||||
Platform-specific implementations are wired in via `#define` macros in
|
||||
each platform's `displayplatform.h` / `inputplatform.h` etc., which
|
||||
the core calls through. Functions that a platform does not support are
|
||||
simply left undefined — the core guards calls with `#ifdef`.
|
||||
|
||||
### Adding platform-specific code
|
||||
- Put it under `src/dusk<platform>/` in the matching subsystem folder.
|
||||
- Gate any core call-site with the appropriate `#ifdef DUSK_<PLATFORM>`
|
||||
or capability macro.
|
||||
- Keep the `src/dusk/` core free of platform ifdefs — delegate through
|
||||
the platform header macros instead.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new asset loader type
|
||||
1. Add an enum value to `assetloadertype_t` (before `_COUNT`) in
|
||||
`src/dusk/asset/loader/assetloader.h`.
|
||||
2. Add fields to the input/loading/output unions in `assetloader.h`.
|
||||
3. Implement `assetXxxLoaderSync`, `assetXxxLoaderAsync`, and
|
||||
`assetXxxDispose` in a new `src/dusk/asset/loader/xxx/` directory.
|
||||
4. Register the three callbacks in `ASSET_LOADER_CALLBACKS[]` in
|
||||
`src/dusk/asset/loader/assetloader.c`.
|
||||
5. If user-facing, create a JS module (see below) and a `.d.ts` file.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new entity component
|
||||
1. Create `src/dusk/entity/component/<category>/entityMyComp.h/.c` with
|
||||
struct `entityMyComp_t`, `entityMyCompInit()`, and optionally
|
||||
`entityMyCompDispose()`.
|
||||
2. Add the include to `src/dusk/entity/componentlist.h` header block.
|
||||
3. Add a row to `src/dusk/entity/componentlist.h`:
|
||||
```c
|
||||
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
|
||||
```
|
||||
This auto-generates the enum, union field, and definition entry.
|
||||
4. If JS-facing, create the script module and `.d.ts` (see below).
|
||||
|
||||
---
|
||||
|
||||
## Adding a new script (JS) module
|
||||
1. Create `src/dusk/script/module/<category>/moduleMyMod.h/.c`.
|
||||
- Declare `extern scriptproto_t MODULE_MYMOD_PROTO;` in the header.
|
||||
- Use `moduleBaseFunction(name)` to define JS-callable functions.
|
||||
- Register props/funcs in `moduleMyModInit()` with
|
||||
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
|
||||
`scriptProtoDefineStaticFunc`.
|
||||
2. `#include` the header in
|
||||
`src/dusk/script/module/modulelist.c` and call
|
||||
`moduleMyModInit()` in `moduleListInit()` (and `Dispose` in
|
||||
`moduleListDispose()`).
|
||||
3. For component modules also register in
|
||||
`src/dusk/script/module/entity/component/modulecomponentlist.c`
|
||||
so `entity.add()` returns the typed wrapper.
|
||||
4. Create `types/<category>/mymod.d.ts` and add a
|
||||
`/// <reference path="..." />` line to `types/index.d.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Script module type declarations
|
||||
Whenever a `src/dusk/script/module/**/*.c` file is created or modified,
|
||||
check whether the corresponding `types/**/*.d.ts` needs updating and
|
||||
apply any changes before finishing the task.
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (asset scripts)
|
||||
- Use `var` for module-level state; `const` for values that never
|
||||
change.
|
||||
- Always use semicolons.
|
||||
- Scene objects are plain objects (`var scene = {}`) with assigned
|
||||
methods.
|
||||
- Export via `module.exports = scene`.
|
||||
- Async scene init should use `async function` and `await`.
|
||||
|
||||
---
|
||||
|
||||
## Coding style
|
||||
|
||||
### ASCII only
|
||||
Source files (`.c`, `.h`, `.js`) must contain only ASCII characters (U+0000–U+007F).
|
||||
Non-ASCII characters are banned even in comments and string literals.
|
||||
Use ASCII-only substitutes instead:
|
||||
- `--` or `-` instead of `—` (em dash)
|
||||
- `->` instead of `→` (arrow)
|
||||
- `x` or `*` instead of `×` (multiplication)
|
||||
|
||||
Only non-script asset files (e.g. `.po` locale files) may contain non-ASCII text.
|
||||
|
||||
### Indentation
|
||||
2 spaces. No tabs.
|
||||
|
||||
### Keyword and operator spacing
|
||||
No space between a keyword or function name and its opening parenthesis:
|
||||
|
||||
```c
|
||||
if(!ptr) return;
|
||||
for(uint8_t i = 0; i < count; i++) {
|
||||
while(entry->state != DONE) {
|
||||
switch(type) {
|
||||
sizeof(assetbatch_t)
|
||||
memoryZero(ptr, size)
|
||||
```
|
||||
|
||||
Spaces around all binary operators and after every comma:
|
||||
|
||||
```c
|
||||
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
|
||||
(size_t)end - (size_t)start
|
||||
foo(a, b, c)
|
||||
```
|
||||
|
||||
### Braces
|
||||
Opening brace on the **same line** as the statement (K&R style) for all
|
||||
constructs — functions, `if`, `else`, `for`, `while`, `switch`:
|
||||
|
||||
```c
|
||||
void assetEntryLock(assetentry_t *entry) {
|
||||
...
|
||||
}
|
||||
|
||||
if(dirty) {
|
||||
...
|
||||
} else {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Guard returns
|
||||
Short guards go on one line with no braces:
|
||||
|
||||
```c
|
||||
if(!ptr) return;
|
||||
if(!b || !b->batch) return jerry_undefined();
|
||||
if(!(flags & DIRTY)) return;
|
||||
```
|
||||
|
||||
### Blank lines
|
||||
- One blank line between functions; no blank line at the start or end of
|
||||
a function body.
|
||||
- One blank line between logical blocks inside a function body.
|
||||
- No trailing blank lines at the end of a file.
|
||||
|
||||
### Pointer placement
|
||||
`*` is attached to the variable name, not the type:
|
||||
|
||||
```c
|
||||
assetentry_t *entry
|
||||
const char_t *name
|
||||
void *ptr
|
||||
uint8_t *d = (uint8_t *)dest;
|
||||
```
|
||||
|
||||
### Casts
|
||||
Space between cast and operand:
|
||||
|
||||
```c
|
||||
(assetbatch_t *)user
|
||||
(uint8_t *)dest
|
||||
(textureformat_t)v
|
||||
```
|
||||
|
||||
### Return
|
||||
No parentheses around the return value:
|
||||
|
||||
```c
|
||||
return ptr;
|
||||
return MEMORY_POINTERS_IN_USE;
|
||||
```
|
||||
|
||||
### switch / case
|
||||
`case` indented 2 spaces from `switch`; body indented 2 more from `case`:
|
||||
|
||||
```c
|
||||
switch(type) {
|
||||
case ASSET_LOADER_TYPE_TEXTURE:
|
||||
descs[i].input.texture = (textureformat_t)v;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-line function signatures
|
||||
When parameters don't fit on one line, put each on its own line indented
|
||||
2 spaces; the closing `) {` (definition) or `);` (declaration) goes on
|
||||
its own line at column 0:
|
||||
|
||||
```c
|
||||
void assetEntryInit(
|
||||
assetentry_t *entry,
|
||||
const char_t *name,
|
||||
const assetloadertype_t type,
|
||||
assetloaderinput_t *input
|
||||
) {
|
||||
|
||||
errorret_t memoryCompare(
|
||||
const void *a,
|
||||
const void *b,
|
||||
const size_t size
|
||||
);
|
||||
```
|
||||
|
||||
### Structs and enums
|
||||
Anonymous inner struct or enum with a `typedef`, `_t` suffix, closing
|
||||
brace and name on the same line:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
errorcode_t code;
|
||||
char_t *message;
|
||||
} errorstate_t;
|
||||
|
||||
typedef enum {
|
||||
ASSET_LOADER_TYPE_NULL,
|
||||
ASSET_LOADER_TYPE_COUNT
|
||||
} assetloadertype_t;
|
||||
```
|
||||
|
||||
### Designated initialisers
|
||||
Spaces inside braces; `.field = value`:
|
||||
|
||||
```c
|
||||
jsassetentry_t e = { .entry = entry };
|
||||
assetbatchloadedpend_t init = { .batch = batch };
|
||||
```
|
||||
|
||||
### Ternary operator
|
||||
Spaces around `?` and `:`:
|
||||
|
||||
```c
|
||||
const float val = psx > 0.0f ? pt[0][0] / psx : 0.0f;
|
||||
```
|
||||
|
||||
### const placement
|
||||
`const` before the type, `*` attached to the variable:
|
||||
|
||||
```c
|
||||
const char_t *name
|
||||
const void *src
|
||||
const size_t size
|
||||
```
|
||||
|
||||
### Comments in `.c` files
|
||||
- Do not use section dividers (`/* ---- ... ---- */`). Just let the
|
||||
functions follow one another with a single blank line between them.
|
||||
- Multi-line explanatory comments inside function bodies use `//` lines:
|
||||
```c
|
||||
// Script modules are freed; orphaned JS wrapper objects now get GC'd
|
||||
// so their finalizers fire before assetDispose() checks ref counts.
|
||||
jerry_heap_gc(JERRY_GC_PRESSURE_HIGH);
|
||||
```
|
||||
- Do not use `/* */` for inline or inline-block comments inside `.c`
|
||||
function bodies.
|
||||
|
||||
### Comments in `.h` files
|
||||
Every public declaration gets a Javadoc block (`/** … */`) with
|
||||
`@param` and `@returns` where relevant. Keep it on the lines immediately
|
||||
above the declaration with no blank line in between.
|
||||
|
||||
---
|
||||
|
||||
## Color system
|
||||
|
||||
Colors are defined in `src/dusk/display/color.csv` and code-generated
|
||||
into a `color.h` header by `tools/color/csv/__main__.py`.
|
||||
|
||||
Each row in the CSV has `name,r,g,b,a` with channel values in `[0.0, 1.0]`.
|
||||
The script emits four `#define` variants per color plus a bare alias:
|
||||
|
||||
```
|
||||
COLOR_<NAME>_4B color4b(r8, g8, b8, a8) // default alias target
|
||||
COLOR_<NAME>_3B color3b(r8, g8, b8)
|
||||
COLOR_<NAME>_3F color3f(rf, gf, bf)
|
||||
COLOR_<NAME>_4F color4f(rf, gf, bf, af)
|
||||
COLOR_<NAME> COLOR_<NAME>_4B
|
||||
```
|
||||
|
||||
`color_t` is `color4b_t` (four `uint8_t` channels).
|
||||
|
||||
To add a new color, append a row to `color.csv` and rebuild — do not
|
||||
hand-edit the generated header.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
- Tests live in `test/` mirroring `src/dusk/` structure.
|
||||
- Use cmocka; include `dusktest.h`.
|
||||
- Test functions: `static void test_something(void **state)`.
|
||||
- After each test, assert `memoryGetAllocatedCount() == 0` to catch
|
||||
leaks.
|
||||
- Build with `-DDUSK_BUILD_TESTS=ON`.
|
||||
+11
-2
@@ -13,6 +13,7 @@ cmake_policy(SET CMP0079 NEW)
|
||||
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
|
||||
|
||||
option(DUSK_BUILD_TESTS "Enable tests" OFF)
|
||||
option(DUSK_NETWORK "Enable network support" ON)
|
||||
|
||||
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
|
||||
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
|
||||
@@ -90,6 +91,12 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME}
|
||||
DUSK_VERSION="${DUSK_VERSION}"
|
||||
)
|
||||
|
||||
if(DUSK_NETWORK)
|
||||
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
DUSK_NETWORK
|
||||
)
|
||||
endif()
|
||||
|
||||
# Toolchains
|
||||
include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake)
|
||||
|
||||
@@ -121,8 +128,10 @@ add_custom_command(
|
||||
OUTPUT "${DUSK_ASSETS_ZIP}"
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f "${DUSK_ASSETS_ZIP}"
|
||||
COMMAND ${CMAKE_COMMAND} -E tar "cf" "${DUSK_ASSETS_ZIP}" --format=zip -- .
|
||||
WORKING_DIRECTORY "${DUSK_ASSETS_DIR}"
|
||||
COMMAND ${Python3_EXECUTABLE} -m tools.asset.pack
|
||||
--input "${DUSK_ASSETS_DIR}"
|
||||
--output "${DUSK_ASSETS_ZIP}"
|
||||
WORKING_DIRECTORY "${DUSK_ROOT_DIR}"
|
||||
DEPENDS ${DUSK_ASSET_FILES}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,11 +5,107 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
|
||||
|
||||
# Initial Scene
|
||||
msgid "initial.checking_save.title"
|
||||
msgstr "Checking for save data"
|
||||
|
||||
msgid "initial.checking_save.message"
|
||||
msgstr "Please wait..."
|
||||
|
||||
msgid "initial.no_device.title"
|
||||
msgstr "No Save Device Found"
|
||||
|
||||
msgid "initial.no_device.message"
|
||||
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
|
||||
|
||||
msgid "initial.no_device.retry"
|
||||
msgstr "Try again"
|
||||
|
||||
msgid "initial.no_device.continue"
|
||||
msgstr "Continue without saving"
|
||||
|
||||
|
||||
# Main Menu Scene
|
||||
msgid "main_menu.start_game"
|
||||
msgstr "Start Game"
|
||||
|
||||
msgid "main_menu.options"
|
||||
msgstr "Options"
|
||||
|
||||
msgid "main_menu.quit"
|
||||
msgstr "Quit Game"
|
||||
|
||||
msgid "main_menu.quit_confirm"
|
||||
msgstr "Are you sure you want to quit?"
|
||||
|
||||
msgid "main_menu.checking_save.title"
|
||||
msgstr "Checking for save data"
|
||||
|
||||
msgid "main_menu.checking_save.message"
|
||||
msgstr "Please wait..."
|
||||
|
||||
msgid "main_menu.no_device.title"
|
||||
msgstr "No Save Device Found"
|
||||
|
||||
msgid "main_menu.no_device.message"
|
||||
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
|
||||
|
||||
msgid "main_menu.no_device.retry"
|
||||
msgstr "Try again"
|
||||
|
||||
msgid "main_menu.no_device.continue"
|
||||
msgstr "Continue without saving"
|
||||
|
||||
msgid "main_menu.save_load_error.title"
|
||||
msgstr "Error"
|
||||
|
||||
msgid "main_menu.save_load_error.message"
|
||||
msgstr "Failed to load save data. Please try again."
|
||||
|
||||
msgid "main_menu.save_load_error.retry"
|
||||
msgstr "Try Again"
|
||||
|
||||
|
||||
# Select Save Screen
|
||||
msgid "ui.select_save.title"
|
||||
msgstr "Select Save"
|
||||
|
||||
msgid "ui.select_save.empty"
|
||||
msgstr "Empty Slot"
|
||||
|
||||
msgid "ui.select_save.slot_format"
|
||||
msgstr "%s Lv.%d %s"
|
||||
|
||||
msgid "ui.select_save.delete_mode"
|
||||
msgstr "Delete a Save"
|
||||
|
||||
msgid "ui.select_save.delete_confirm"
|
||||
msgstr "Are you sure you want to delete this save?"
|
||||
|
||||
msgid "ui.select_save.name_title"
|
||||
msgstr "Enter game save file name"
|
||||
|
||||
msgid "ui.save_slot.number_format"
|
||||
msgstr "Slot %d"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#: ui/menu.c:10
|
||||
msgid "ui.title"
|
||||
msgstr ""
|
||||
"Welcome"
|
||||
|
||||
msgid "save.linux.mkdirp_failed"
|
||||
msgstr "Failed to create save directory, check the disk is not full or write-protected."
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.general"
|
||||
msgstr "General"
|
||||
@@ -56,6 +152,114 @@ msgstr "Items"
|
||||
msgid "ui.game_menu.settings"
|
||||
msgstr "Settings"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save_success"
|
||||
msgstr "Game saved."
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save_cancelled"
|
||||
msgstr "Save cancelled."
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save_unavailable"
|
||||
msgstr "Can't save - no save device found."
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save_temporary"
|
||||
msgstr "This session is temporary - no save device was found, so saving is disabled."
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save_create_confirm"
|
||||
msgstr "No save data found. Create a new save?"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save_failed_format"
|
||||
msgstr "Save failed: %s"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.save_check_failed_format"
|
||||
msgstr "Can't save: %s"
|
||||
|
||||
#: src/dusk/ui/frame/initial/uiinitialnocard.c
|
||||
msgid "ui.initial.no_card.message"
|
||||
msgstr "No save device found. You can continue, but\nprogress will not be saved."
|
||||
|
||||
#: src/dusk/ui/frame/initial/uiinitialnocard.c
|
||||
msgid "ui.initial.no_card.retry"
|
||||
msgstr "Retry"
|
||||
|
||||
#: src/dusk/ui/frame/initial/uiinitialnocard.c
|
||||
msgid "ui.initial.no_card.continue"
|
||||
msgstr "Continue Anyway"
|
||||
|
||||
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
|
||||
msgid "ui.initial.create_save.message"
|
||||
msgstr "No save data found. Create a new save?"
|
||||
|
||||
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
|
||||
msgid "ui.initial.create_save.yes"
|
||||
msgstr "Yes"
|
||||
|
||||
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
|
||||
msgid "ui.initial.create_save.no"
|
||||
msgstr "No"
|
||||
|
||||
#: src/dusk/ui/frame/uiconfirm.c
|
||||
msgid "ui.confirm.confirm"
|
||||
msgstr "Confirm"
|
||||
|
||||
#: src/dusk/ui/frame/uiconfirm.c
|
||||
msgid "ui.confirm.cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
#: src/dusk/ui/frame/battle/uibattlemenu.c
|
||||
msgid "ui.battle.menu.attack"
|
||||
msgstr "Attack"
|
||||
|
||||
#: src/dusk/ui/frame/battle/uibattlemenu.c
|
||||
msgid "ui.battle.menu.flee"
|
||||
msgstr "Flee"
|
||||
|
||||
#: src/dusk/ui/frame/battle/uibattlemenu.c
|
||||
msgid "ui.battle.menu.target_format"
|
||||
msgstr "Enemy %u (%u/%u HP)"
|
||||
|
||||
#: src/dusk/ui/frame/battle/uibattlehud.c
|
||||
msgid "ui.battle.hud.hp_format"
|
||||
msgstr "HP %u/%u"
|
||||
|
||||
#: src/dusk/ui/frame/battle/uibattlehud.c
|
||||
msgid "ui.battle.hud.mp_format"
|
||||
msgstr "MP %u/%u"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettingsaudio.c
|
||||
msgid "ui.settings.audio.placeholder"
|
||||
msgstr "No audio settings yet"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettingsdisplay.c
|
||||
msgid "ui.settings.display.placeholder"
|
||||
msgstr "No display settings yet"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettingsinput.c
|
||||
msgid "ui.settings.input.placeholder"
|
||||
msgstr "No input settings yet"
|
||||
|
||||
#: src/dusk/ui/frame/backpack/uibackpack.c
|
||||
msgid "ui.backpack.category_format"
|
||||
msgstr "Category %u"
|
||||
|
||||
#: src/dusk/ui/overlay/uiloading.c
|
||||
msgid "ui.loading.text"
|
||||
msgstr "loading"
|
||||
|
||||
#: src/dusk/ui/overlay/uiautosave.c
|
||||
msgid "ui.autosave.saving"
|
||||
msgstr "SAVING"
|
||||
|
||||
msgid "item.potion.name"
|
||||
msgstr "Potion"
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: ExampleApp 1.0\n"
|
||||
"Language: es\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n==1 ? 0 : 1);\n"
|
||||
|
||||
#: ui/menu.c:10
|
||||
msgid "ui.title"
|
||||
msgstr ""
|
||||
"Bienvenido"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.general"
|
||||
msgstr "General"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.input"
|
||||
msgstr "Entrada"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.display"
|
||||
msgstr "Pantalla"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.audio"
|
||||
msgstr "Audio"
|
||||
|
||||
msgid "ui.settings.input.deadzone"
|
||||
msgstr "Deadzone"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
||||
msgid "ui.settings.general.language"
|
||||
msgstr "Idioma"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
||||
msgid "ui.settings.general.language_detail"
|
||||
msgstr "Se aplica después de reiniciar la aplicación."
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.apply"
|
||||
msgstr "Aplicar"
|
||||
|
||||
#: src/dusk/ui/frame/uiconfirm.c
|
||||
msgid "ui.confirm.discard_changes"
|
||||
msgstr "¿Descartar los cambios no guardados?"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.characters"
|
||||
msgstr "Personajes"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.items"
|
||||
msgstr "Objetos"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.settings"
|
||||
msgstr "Configuración"
|
||||
|
||||
#: src/dusk/rpg/item/item.csv
|
||||
msgid "item.potion.name"
|
||||
msgstr "Poción"
|
||||
|
||||
#: src/dusk/rpg/item/item.csv
|
||||
msgid "item.potato.name"
|
||||
msgstr "Papa"
|
||||
|
||||
#: src/dusk/rpg/item/item.csv
|
||||
msgid "item.apple.name"
|
||||
msgstr "Manzana"
|
||||
@@ -1,70 +0,0 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: ExampleApp 1.0\n"
|
||||
"Language: ja\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Plural-Forms: nplurals=1; plural=(0);\n"
|
||||
|
||||
#: ui/menu.c:10
|
||||
msgid "ui.title"
|
||||
msgstr ""
|
||||
"歓迎"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.general"
|
||||
msgstr "一般"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.input"
|
||||
msgstr "入力"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.display"
|
||||
msgstr "表示"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.audio"
|
||||
msgstr "オーディオ"
|
||||
|
||||
msgid "ui.settings.input.deadzone"
|
||||
msgstr "デッドゾーン"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
||||
msgid "ui.settings.general.language"
|
||||
msgstr "言語"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
||||
msgid "ui.settings.general.language_detail"
|
||||
msgstr "アプリケーションを再起動すると適用されます。"
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.apply"
|
||||
msgstr "適用"
|
||||
|
||||
#: src/dusk/ui/frame/uiconfirm.c
|
||||
msgid "ui.confirm.discard_changes"
|
||||
msgstr "未保存の変更を破棄しますか?"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.characters"
|
||||
msgstr "キャラクター"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.items"
|
||||
msgstr "アイテム"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
msgid "ui.game_menu.settings"
|
||||
msgstr "設定"
|
||||
|
||||
#: src/dusk/rpg/item/item.csv
|
||||
msgid "item.potion.name"
|
||||
msgstr "ポーション"
|
||||
|
||||
#: src/dusk/rpg/item/item.csv
|
||||
msgid "item.potato.name"
|
||||
msgstr "ジャガイモ"
|
||||
|
||||
#: src/dusk/rpg/item/item.csv
|
||||
msgid "item.apple.name"
|
||||
msgstr "リンゴ"
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -2179,5 +2179,27 @@
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"type": "global",
|
||||
"globalId": 3,
|
||||
"pos": [8, 8, 1]
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"itemId": 1,
|
||||
"quantity": 1,
|
||||
"pos": [12, 2, 0]
|
||||
}
|
||||
],
|
||||
"areas": [
|
||||
{
|
||||
"min": [11, 3, 0],
|
||||
"max": [16, 9, 10],
|
||||
"callbackId": 1,
|
||||
"notify": 3,
|
||||
"trigger": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"items": [
|
||||
// Boot check: make sure a save device is available before handing off
|
||||
// to the main menu.
|
||||
{
|
||||
"type": "MODAL",
|
||||
"title": "initial.checking_save.title",
|
||||
"message": "initial.checking_save.message"
|
||||
},
|
||||
{
|
||||
"type": "WAIT",
|
||||
"seconds": 0.2
|
||||
},
|
||||
{
|
||||
"type": "SAVE_DEVICE_CHECK",
|
||||
"successMarker": "CONTINUE",
|
||||
"failureMarker": "NO_DEVICE"
|
||||
},
|
||||
|
||||
// No save device found - offer to retry or continue without saving.
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "NO_DEVICE"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_CLOSE"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_OPTIONS_MARKERS",
|
||||
"title": "initial.no_device.title",
|
||||
"message": "initial.no_device.message",
|
||||
"options": [
|
||||
{
|
||||
"text": "initial.no_device.retry",
|
||||
"marker": "RETRY"
|
||||
},
|
||||
{
|
||||
"text": "initial.no_device.continue",
|
||||
"marker": "CONTINUE"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "RETRY"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_CLOSE"
|
||||
},
|
||||
{
|
||||
"type": "RESTART"
|
||||
},
|
||||
|
||||
// Save device found (or continuing without one) - hand off to the
|
||||
// main menu scene.
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "CONTINUE"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_CLOSE"
|
||||
},
|
||||
{
|
||||
"type": "SCENE",
|
||||
"sceneType": "MAIN_MENU"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"type": "MODAL",
|
||||
"title": "main_menu.checking_save.title",
|
||||
"message": "main_menu.checking_save.message"
|
||||
},
|
||||
{
|
||||
"type": "WAIT",
|
||||
"seconds": 0.2
|
||||
},
|
||||
{
|
||||
"type": "SAVE_DEVICE_CHECK",
|
||||
"successMarker": "CONTINUE",
|
||||
"failureMarker": "NO_DEVICE"
|
||||
},
|
||||
|
||||
// No save device found - offer to retry or continue without saving.
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "NO_DEVICE"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_CLOSE"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_OPTIONS_MARKERS",
|
||||
"title": "main_menu.no_device.title",
|
||||
"message": "main_menu.no_device.message",
|
||||
"options": [
|
||||
{
|
||||
"text": "main_menu.no_device.retry",
|
||||
"marker": "RETRY"
|
||||
},
|
||||
{
|
||||
"text": "main_menu.no_device.continue",
|
||||
"marker": "CONTINUE"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// Retry
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "RETRY"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_CLOSE"
|
||||
},
|
||||
{
|
||||
"type": "RESTART"
|
||||
},
|
||||
|
||||
// Save device found - attempt to load all save slots.
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "CONTINUE"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_CLOSE"
|
||||
},
|
||||
{
|
||||
"type": "SAVE_LOAD_ALL_SLOTS",
|
||||
"successMarker": "LOADED",
|
||||
"failureMarker": "LOAD_ERROR"
|
||||
},
|
||||
|
||||
// Save data failed to load (e.g. corrupt/unreadable) - only option is
|
||||
// to retry, no "continue without saving" here since we already know a
|
||||
// device is present.
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "LOAD_ERROR"
|
||||
},
|
||||
{
|
||||
"type": "MODAL_OPTIONS_MARKERS",
|
||||
"title": "main_menu.save_load_error.title",
|
||||
"message": "main_menu.save_load_error.message",
|
||||
"options": [
|
||||
{
|
||||
"text": "main_menu.save_load_error.retry",
|
||||
"marker": "RETRY"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"type": "MARKER",
|
||||
"name": "LOADED"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,9 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
DOL=1
|
||||
ISO=2
|
||||
DUSK_DOLPHIN_BUILD_TYPE=${DUSK_DOLPHIN_BUILD_TYPE}
|
||||
# GameCube/Wii PowerPC is always big-endian; declare it at compile time
|
||||
# like every other target instead of relying on endian.h's runtime probe.
|
||||
DUSK_PLATFORM_ENDIAN_BIG
|
||||
)
|
||||
|
||||
# Custom compiler flags
|
||||
@@ -58,6 +61,7 @@ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
|
||||
zstd
|
||||
z
|
||||
lzma
|
||||
ansnd
|
||||
)
|
||||
|
||||
if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
|
||||
|
||||
@@ -56,6 +56,7 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
||||
DUSK_DISPLAY_HEIGHT=272
|
||||
DUSK_THREAD_PTHREAD
|
||||
DUSK_TIME_DYNAMIC
|
||||
DUSK_DISPLAY_OVERSCAN=6
|
||||
)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
@@ -64,6 +65,23 @@ if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
)
|
||||
endif()
|
||||
|
||||
# Generate PARAM.SFO as a normal tracked build output (instead of letting
|
||||
# create_pbp_file() auto-generate + delete it) so it can be reused below by
|
||||
# a properly dependency-tracked EBOOT.PBP repack step.
|
||||
set(DUSK_PSP_PARAM_SFO "${DUSK_BUILD_DIR}/PARAM.SFO")
|
||||
add_custom_command(
|
||||
OUTPUT "${DUSK_PSP_PARAM_SFO}"
|
||||
COMMAND "$ENV{PSPDEV}/bin/mksfoex" "-d" "MEMSIZE=1" "-s" "APP_VER=01.00"
|
||||
"${DUSK_BINARY_TARGET_NAME}" "${DUSK_PSP_PARAM_SFO}"
|
||||
COMMENT "Generating PARAM.SFO for ${DUSK_BINARY_TARGET_NAME}"
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(DuskPspParamSfo DEPENDS "${DUSK_PSP_PARAM_SFO}")
|
||||
|
||||
# create_pbp_file()'s own POST_BUILD chain (below) also consumes
|
||||
# DUSK_PSP_PARAM_SFO, so make sure it exists before that chain runs.
|
||||
add_dependencies(${DUSK_BINARY_TARGET_NAME} DuskPspParamSfo)
|
||||
|
||||
# Postbuild, create .pbp file for PSP.
|
||||
create_pbp_file(
|
||||
TARGET "${DUSK_BINARY_TARGET_NAME}"
|
||||
@@ -73,4 +91,33 @@ create_pbp_file(
|
||||
TITLE "${DUSK_BINARY_TARGET_NAME}"
|
||||
PSAR_PATH ${DUSK_ASSETS_ZIP}
|
||||
VERSION 01.00
|
||||
SFO_PATH "${DUSK_PSP_PARAM_SFO}"
|
||||
OUTPUT_DIR "${DUSK_BUILD_DIR}"
|
||||
)
|
||||
|
||||
# CreatePBP.cmake's pack-pbp step is a POST_BUILD command tied to the
|
||||
# executable target, so it only reruns when the ELF itself relinks. That
|
||||
# means regenerating dusk.dsk (assets) alone, without touching any C
|
||||
# source, silently leaves EBOOT.PBP embedding a stale asset pak. Repack it
|
||||
# here as a normal file-tracked custom command depending on both the
|
||||
# executable and the asset zip, so EBOOT.PBP always reflects the current
|
||||
# assets even when nothing else about the build changed.
|
||||
set(DUSK_PSP_EBOOT "${DUSK_BUILD_DIR}/EBOOT.PBP")
|
||||
if(BUILD_PRX)
|
||||
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>.prx")
|
||||
else()
|
||||
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>")
|
||||
endif()
|
||||
add_custom_command(
|
||||
OUTPUT "${DUSK_PSP_EBOOT}"
|
||||
COMMAND "$ENV{PSPDEV}/bin/pack-pbp" "${DUSK_PSP_EBOOT}" "${DUSK_PSP_PARAM_SFO}"
|
||||
"NULL" "NULL" "NULL" "NULL" "NULL"
|
||||
"${DUSK_PSP_EXECUTABLE}" "${DUSK_ASSETS_ZIP}"
|
||||
DEPENDS
|
||||
"$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>"
|
||||
"${DUSK_PSP_PARAM_SFO}"
|
||||
"${DUSK_ASSETS_ZIP}"
|
||||
COMMENT "Repacking EBOOT.PBP (tracks executable + asset pak freshness)"
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(DuskPspEbootRepack ALL DEPENDS "${DUSK_PSP_EBOOT}")
|
||||
@@ -4,6 +4,17 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
DUSK_WII
|
||||
)
|
||||
|
||||
# Wii save storage method - see src/duskdolphin/save/savedeviceplatform.h.
|
||||
set(DUSK_SAVE_WII_METHOD "NAND" CACHE STRING
|
||||
"Wii save storage: NAND (internal storage via ISFS), CARD (GameCube-\
|
||||
compatible memory card emulation), or SD (SD card via libfat)"
|
||||
)
|
||||
set_property(CACHE DUSK_SAVE_WII_METHOD PROPERTY STRINGS "NAND" "CARD" "SD")
|
||||
|
||||
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
DUSK_SAVE_WII_METHOD_${DUSK_SAVE_WII_METHOD}
|
||||
)
|
||||
|
||||
# Generate Homebrew Channel meta.xml from project identity variables
|
||||
string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC)
|
||||
configure_file(
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
add_subdirectory(dusk)
|
||||
|
||||
if(DUSK_NETWORK)
|
||||
add_subdirectory(dusknetwork)
|
||||
endif()
|
||||
|
||||
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
|
||||
add_subdirectory(dusklinux)
|
||||
add_subdirectory(dusksdl2)
|
||||
|
||||
@@ -14,6 +14,13 @@ if(NOT libzip_FOUND)
|
||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC zip)
|
||||
endif()
|
||||
|
||||
# assetdsk.c calls crc32() directly (to verify dusk.dsk archive checksums).
|
||||
# find_package(libzip) above already resolves ZLIB as a side effect, so
|
||||
# ZLIB_FOUND may already be true without ZLIB::ZLIB having been linked to
|
||||
# our target - link it unconditionally rather than guarding on ZLIB_FOUND.
|
||||
find_package(ZLIB REQUIRED)
|
||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC ZLIB::ZLIB)
|
||||
|
||||
if(NOT stb_image_FOUND)
|
||||
find_package(stb REQUIRED)
|
||||
if(STB_IMAGE_FOUND)
|
||||
@@ -53,9 +60,9 @@ target_sources(${DUSK_BINARY_TARGET_NAME}
|
||||
|
||||
# Subdirs
|
||||
add_subdirectory(animation)
|
||||
add_subdirectory(event)
|
||||
add_subdirectory(assert)
|
||||
add_subdirectory(asset)
|
||||
add_subdirectory(audio)
|
||||
add_subdirectory(console)
|
||||
add_subdirectory(display)
|
||||
add_subdirectory(log)
|
||||
@@ -68,7 +75,6 @@ add_subdirectory(scene)
|
||||
add_subdirectory(system)
|
||||
add_subdirectory(time)
|
||||
add_subdirectory(ui)
|
||||
add_subdirectory(network)
|
||||
add_subdirectory(save)
|
||||
add_subdirectory(util)
|
||||
add_subdirectory(thread)
|
||||
@@ -7,4 +7,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
easing.c
|
||||
animation.c
|
||||
keyframe.c
|
||||
)
|
||||
|
||||
+103
-23
@@ -11,42 +11,122 @@
|
||||
void animationInit(
|
||||
animation_t *anim,
|
||||
keyframe_t *keyframes,
|
||||
uint16_t keyframeCount
|
||||
uint16_t *keyframeCounts,
|
||||
const uint16_t layerCount
|
||||
) {
|
||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
||||
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
|
||||
assertNotNull(keyframeCounts, "Keyframe counts pointer cannot be null.");
|
||||
assertTrue(layerCount > 0, "Layer count must be greater than zero.");
|
||||
|
||||
memoryZero(anim, sizeof(animation_t));
|
||||
anim->keyframes = keyframes;
|
||||
anim->keyframeCount = keyframeCount;
|
||||
anim->keyframeCounts = keyframeCounts;
|
||||
anim->layerCount = layerCount;
|
||||
|
||||
// Determine duration
|
||||
float_t duration = 0.0f;
|
||||
for(uint16_t layer = 0; layer < layerCount; layer++) {
|
||||
uint16_t keyframeCount = keyframeCounts[layer];
|
||||
assertTrue(keyframeCount > 0, "Keyframe count invalid.");
|
||||
keyframe_t *layerKeyframes = keyframes + layer * keyframeCount;
|
||||
|
||||
#ifdef DUSK_ASSERTIONS
|
||||
// Check that the keyframes are sorted by time.
|
||||
for(uint16_t i = 1; i < keyframeCount; i++) {
|
||||
assertTrue(
|
||||
layerKeyframes[i].time >= layerKeyframes[i - 1].time,
|
||||
"Keyframes must be sorted by time."
|
||||
);
|
||||
}
|
||||
#endif
|
||||
|
||||
keyframe_t *lastKeyframe = layerKeyframes + keyframeCount - 1;
|
||||
duration = mathMax(duration, lastKeyframe->time);
|
||||
}
|
||||
assertTrue(duration > 0, "Animation duration must be greater than 0.");
|
||||
anim->duration = duration;
|
||||
}
|
||||
|
||||
float_t animationGetValue(animation_t *anim, const float_t time) {
|
||||
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer) {
|
||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||
assertNotNull(anim->keyframes, "Keyframes pointer cannot be null.");
|
||||
assertTrue(anim->keyframeCount > 0, "Keyframe count invalid.");
|
||||
assertTrue(time >= 0, "Time must be non-negative.");
|
||||
assertTrue(layer < anim->layerCount, "Layer index out of bounds.");
|
||||
|
||||
keyframe_t *start;
|
||||
keyframe_t *end;
|
||||
keyframe_t *last = anim->keyframes + anim->keyframeCount - 1;
|
||||
keyframe_t *current = anim->keyframes;
|
||||
start = current;
|
||||
uint16_t keyframeCount = anim->keyframeCounts[layer];
|
||||
keyframe_t *layerKeyframes = anim->keyframes + layer * keyframeCount;
|
||||
return keyframeGetValue(layerKeyframes, keyframeCount, anim->time);
|
||||
}
|
||||
|
||||
do {
|
||||
if(current->time > time) {
|
||||
end = current;
|
||||
void animationUpdate(
|
||||
animation_t *anim,
|
||||
const float_t deltaTime
|
||||
) {
|
||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||
assertTrue(deltaTime >= 0, "Delta time must be non-negative.");
|
||||
|
||||
bool_t justCompleted = false;
|
||||
|
||||
if(!(anim->flags & ANIMATION_FLAG_INTERNAL_COMPLETED)) {
|
||||
bool_t loop = (anim->flags & ANIMATION_FLAG_LOOP) != 0;
|
||||
bool_t pingpong = (anim->flags & ANIMATION_FLAG_PINGPONG) != 0;
|
||||
assertFalse(
|
||||
loop && pingpong,
|
||||
"Cannot set both ANIMATION_FLAG_LOOP and ANIMATION_FLAG_PINGPONG."
|
||||
);
|
||||
|
||||
bool_t backward;
|
||||
if(pingpong) {
|
||||
backward = (anim->flags & ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD) != 0;
|
||||
} else {
|
||||
backward = (anim->flags & ANIMATION_FLAG_REVERSE) != 0;
|
||||
}
|
||||
|
||||
// Resolve boundary crossings one at a time, so a single large deltaTime
|
||||
// can correctly loop/pingpong across multiple boundaries in one call.
|
||||
float_t remaining = deltaTime;
|
||||
while(remaining > 0.0f) {
|
||||
float_t toBoundary = (
|
||||
backward ? anim->time : (anim->duration - anim->time)
|
||||
);
|
||||
if(remaining < toBoundary) {
|
||||
anim->time += backward ? -remaining : remaining;
|
||||
break;
|
||||
}
|
||||
start = current;
|
||||
current++;
|
||||
|
||||
if(current > last) {
|
||||
end = start;
|
||||
remaining -= toBoundary;
|
||||
anim->time = backward ? 0.0f : anim->duration;
|
||||
|
||||
bool_t stopHere;
|
||||
if(backward) {
|
||||
stopHere = (anim->flags & ANIMATION_FLAG_STOP_BEGINNING) != 0;
|
||||
} else {
|
||||
stopHere = (anim->flags & ANIMATION_FLAG_STOP_END) != 0;
|
||||
}
|
||||
|
||||
if(stopHere) {
|
||||
justCompleted = true;
|
||||
break;
|
||||
} else if(pingpong) {
|
||||
backward = !backward;
|
||||
if(backward) anim->flags |= ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
|
||||
else anim->flags &= ~ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
|
||||
} else if(loop) {
|
||||
anim->time = backward ? anim->duration : 0.0f;
|
||||
if(anim->onLoop) anim->onLoop(anim->user);
|
||||
} else {
|
||||
justCompleted = true;
|
||||
break;
|
||||
}
|
||||
} while(true);
|
||||
|
||||
float_t t = (time - start->time) / (end->time - start->time);
|
||||
return mathLerp(start->value, end->value, easingApply(start->easing, t));
|
||||
}
|
||||
|
||||
if(justCompleted) anim->flags |= ANIMATION_FLAG_INTERNAL_COMPLETED;
|
||||
}
|
||||
|
||||
// Call onUpdate for each layer.
|
||||
for(uint16_t layer = 0; layer < anim->layerCount; layer++) {
|
||||
float_t value = animationGetLayerValue(anim, layer);
|
||||
if(anim->onUpdate) anim->onUpdate(layer, value, anim->user);
|
||||
}
|
||||
|
||||
if(justCompleted && anim->onComplete) anim->onComplete(anim->user);
|
||||
}
|
||||
@@ -6,29 +6,89 @@
|
||||
#pragma once
|
||||
#include "keyframe.h"
|
||||
|
||||
#define ANIMATION_FLAG_LOOP (1 << 0)
|
||||
#define ANIMATION_FLAG_REVERSE (1 << 1)
|
||||
#define ANIMATION_FLAG_PINGPONG (1 << 2)
|
||||
#define ANIMATION_FLAG_STOP_BEGINNING (1 << 3)
|
||||
#define ANIMATION_FLAG_STOP_END (1 << 4)
|
||||
|
||||
// Internal - tracks which direction a pingponging animation is currently
|
||||
// travelling. Do not set this manually, it is managed by animationUpdate().
|
||||
#define ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD (1 << 7)
|
||||
|
||||
// Internal - set once the animation has stopped advancing (see
|
||||
// animationUpdate()). Do not set this manually. There is currently no way to
|
||||
// restart a completed animation short of clearing this bit and resetting
|
||||
// anim->time by hand.
|
||||
#define ANIMATION_FLAG_INTERNAL_COMPLETED (1 << 6)
|
||||
|
||||
typedef struct {
|
||||
keyframe_t *keyframes;
|
||||
uint16_t keyframeCount;
|
||||
uint16_t *keyframeCounts;
|
||||
uint16_t layerCount;
|
||||
float_t time;
|
||||
float_t duration;
|
||||
uint8_t flags;
|
||||
|
||||
void *user;
|
||||
void (*onUpdate)(const uint16_t layer, const float_t value, void *user);
|
||||
void (*onComplete)(void *user);
|
||||
void (*onLoop)(void *user);
|
||||
} animation_t;
|
||||
|
||||
/**
|
||||
* Initializes an animation.
|
||||
* Initializes an animation with the given keyframes and layer count.
|
||||
*
|
||||
* @param anim The animation to initialize.
|
||||
* @param keyframes The keyframes to use for the animation.
|
||||
* @param keyframeCount The number of keyframes in the animation.
|
||||
* @param anim Pointer to the animation to initialize.
|
||||
* @param keyframes Pointer to the array of keyframes for each layer.
|
||||
* @param keyframeCount Number of keyframes in each layer.
|
||||
* @param layerCount Number of layers in the animation.
|
||||
*/
|
||||
void animationInit(
|
||||
animation_t *anim,
|
||||
keyframe_t *keyframes,
|
||||
uint16_t keyframeCount
|
||||
uint16_t *keyframeCounts,
|
||||
const uint16_t layerCount
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the value of the animation at a given time.
|
||||
* Sets the current time of the animation, clamping it to the valid range.
|
||||
* This will call the onUpdate callback but none of the other callbacks.
|
||||
*
|
||||
* @param anim The animation to get the value from.
|
||||
* @param time The time at which to get the value, in seconds.
|
||||
* @return The value of the animation at the given time.
|
||||
* @param anim Pointer to the animation to set the time for.
|
||||
* @param time The new time to set for the animation.
|
||||
*/
|
||||
float_t animationGetValue(animation_t *anim, const float_t time);
|
||||
void animationSetTime(animation_t *anim, const float_t time);
|
||||
|
||||
/**
|
||||
* Gets the current value of a specific layer in the animation based on the
|
||||
* current animation time.
|
||||
*/
|
||||
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer);
|
||||
|
||||
/**
|
||||
* Updates the animation state based on the elapsed time. Advances anim->time
|
||||
* by deltaTime (or against it, if ANIMATION_FLAG_REVERSE is set), then
|
||||
* resolves whatever happens when it reaches the 0 or duration boundary:
|
||||
*
|
||||
* - ANIMATION_FLAG_PINGPONG: reflects off the boundary and continues playing
|
||||
* in the opposite direction, forever, unless stopped (see below).
|
||||
* - ANIMATION_FLAG_LOOP: wraps back around to the other boundary and keeps
|
||||
* playing in the same direction, forever, unless stopped (see below).
|
||||
* - ANIMATION_FLAG_STOP_BEGINNING / ANIMATION_FLAG_STOP_END: when the
|
||||
* animation reaches that specific boundary, it clamps there and stops
|
||||
* (firing onComplete) instead of looping/pingponging past it.
|
||||
* - If none of the above apply at a boundary, the animation clamps there and
|
||||
* stops, firing onComplete.
|
||||
*
|
||||
* onUpdate is called for every layer on every call. onLoop is called each
|
||||
* time a loop wraps around. onComplete is called at most once, the moment
|
||||
* the animation stops advancing.
|
||||
*
|
||||
* @param anim Pointer to the animation to update.
|
||||
* @param deltaTime Time elapsed since the last update (in seconds).
|
||||
*/
|
||||
void animationUpdate(
|
||||
animation_t *anim,
|
||||
const float_t deltaTime
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "keyframe.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/math.h"
|
||||
|
||||
float_t keyframeGetValue(
|
||||
const keyframe_t *keyframes,
|
||||
const uint32_t keyframeCount,
|
||||
const float_t time
|
||||
) {
|
||||
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
||||
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
|
||||
assertTrue(time >= 0, "Time must be non-negative.");
|
||||
#ifdef DUSK_ASSERTIONS
|
||||
// Checks that the keyframes are sorted by time.
|
||||
for(uint32_t i = 1; i < keyframeCount; i++) {
|
||||
assertTrue(
|
||||
keyframes[i].time >= keyframes[i - 1].time,
|
||||
"Keyframes must be sorted by time."
|
||||
);
|
||||
}
|
||||
#endif
|
||||
|
||||
keyframe_t *last = (keyframe_t *)(keyframes + keyframeCount - 1);
|
||||
if(time >= last->time) return last->value;
|
||||
|
||||
// Since time < last->time (checked above), current is guaranteed to stop
|
||||
// at or before reaching last, so no separate end-of-array check is needed.
|
||||
keyframe_t *current = (keyframe_t *)keyframes;
|
||||
keyframe_t *start = current;
|
||||
while(current->time <= time) {
|
||||
start = current;
|
||||
current++;
|
||||
}
|
||||
keyframe_t *end = current;
|
||||
|
||||
float_t t = (time - start->time) / (end->time - start->time);
|
||||
return mathLerp(start->value, end->value, easingApply(start->easing, t));
|
||||
}
|
||||
@@ -11,3 +11,17 @@ typedef struct {
|
||||
float_t value;
|
||||
easingtype_t easing;
|
||||
} keyframe_t;
|
||||
|
||||
/**
|
||||
* Gets the value of a keyframe at a given time.
|
||||
*
|
||||
* @param keyframes The keyframes to get the value from.
|
||||
* @param keyframeCount The number of keyframes in the array.
|
||||
* @param time The time at which to get the value, in seconds.
|
||||
* @return The value of the keyframe at the given time.
|
||||
*/
|
||||
float_t keyframeGetValue(
|
||||
const keyframe_t *keyframes,
|
||||
const uint32_t keyframeCount,
|
||||
const float_t time
|
||||
);
|
||||
@@ -22,6 +22,8 @@
|
||||
#endif
|
||||
|
||||
#ifndef DUSK_ASSERTIONS_FAKED
|
||||
#define DUSK_ASSERTIONS 1
|
||||
|
||||
/**
|
||||
* Initializes the assert system. Must be the very first call in engine
|
||||
* startup.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
asset.c
|
||||
assetbatch.c
|
||||
assetdsk.c
|
||||
assetfile.c
|
||||
)
|
||||
|
||||
|
||||
+70
-54
@@ -19,13 +19,17 @@ asset_t ASSET;
|
||||
errorret_t assetInit(void) {
|
||||
memoryZero(&ASSET, sizeof(asset_t));
|
||||
|
||||
threadMutexInit(&ASSET.zipLock);
|
||||
|
||||
for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) {
|
||||
threadMutexInit(&ASSET.loading[i].mutex);
|
||||
}
|
||||
|
||||
// assetInitPlatform must either define ASSET.zip or throw an error.
|
||||
// assetInitPlatform must either define both ASSET.zip/ASSET.zipStored or
|
||||
// throw an error.
|
||||
errorChain(assetInitPlatform());
|
||||
assertNotNull(ASSET.zip, "Asset zip null without error.");
|
||||
assertNotNull(ASSET.zipStored, "Asset stored zip null without error.");
|
||||
threadInit(&ASSET.loadThread, assetUpdateAsync);
|
||||
threadStart(&ASSET.loadThread);
|
||||
|
||||
@@ -35,9 +39,12 @@ errorret_t assetInit(void) {
|
||||
bool_t assetFileExists(const char_t *filename) {
|
||||
assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long.");
|
||||
|
||||
zip_int64_t idx = zip_name_locate(ASSET.zip, filename, 0);
|
||||
if(idx < 0) return false;
|
||||
return true;
|
||||
threadMutexLock(&ASSET.zipLock);
|
||||
bool_t found =
|
||||
zip_name_locate(ASSET.zip, filename, 0) >= 0 ||
|
||||
zip_name_locate(ASSET.zipStored, filename, 0) >= 0;
|
||||
threadMutexUnlock(&ASSET.zipLock);
|
||||
return found;
|
||||
}
|
||||
|
||||
assetentry_t * assetGetEntry(
|
||||
@@ -59,7 +66,10 @@ assetentry_t * assetGetEntry(
|
||||
entry++;
|
||||
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
|
||||
// We did not find one existing, Find first available slot.
|
||||
// We did not find one existing. Find first available slot, reaping
|
||||
// zero-ref entries to make room if none are immediately available.
|
||||
bool_t reaped = false;
|
||||
for(;;) {
|
||||
entry = ASSET.entries;
|
||||
do {
|
||||
if(entry->type != ASSET_LOADER_TYPE_NULL) {
|
||||
@@ -74,6 +84,11 @@ assetentry_t * assetGetEntry(
|
||||
entry++;
|
||||
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
|
||||
if(reaped) break;
|
||||
reaped = true;
|
||||
errorCatch(assetReapUnused());
|
||||
}
|
||||
|
||||
assertUnreachable("No available asset entry slots.");
|
||||
return NULL;
|
||||
}
|
||||
@@ -109,6 +124,15 @@ errorret_t assetRequireLoaded(assetentry_t *entry) {
|
||||
assetEntryLock(entry);
|
||||
|
||||
while(entry->state != ASSET_ENTRY_STATE_LOADED) {
|
||||
// A failed load transitions to ERROR, not LOADED - without this check
|
||||
// this loop spins forever on any load failure (assetUpdate() itself
|
||||
// still returns OK, since a single asset failing isn't meant to halt
|
||||
// the whole update loop - see its ASSET_ENTRY_STATE_ERROR handling).
|
||||
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
|
||||
assetEntryUnlock(entry);
|
||||
errorThrow("Failed to load asset: %s", entry->name);
|
||||
}
|
||||
|
||||
usleep(1000);
|
||||
errorret_t ret = assetUpdate();
|
||||
if(errorIsNotOk(ret)) {
|
||||
@@ -191,6 +215,32 @@ void assetUnlockEntry(assetentry_t *entry) {
|
||||
assetEntryUnlock(entry);
|
||||
}
|
||||
|
||||
errorret_t assetReapUnused(void) {
|
||||
assertIsMainThread("assetReapUnused must be called from the main thread.");
|
||||
|
||||
// Repeatedly find and dispose zero-ref LOADED entries until none remain.
|
||||
// This handles dependency chains where an entry (e.g. a model) holds refs
|
||||
// on child entries (mesh, texture): dispose parents first so child ref
|
||||
// counts drop to zero, then pick up the children on the next pass. Without
|
||||
// this, a forward-only scan fails when a shared child entry appears before
|
||||
// a parent that still holds a ref to it.
|
||||
bool_t any;
|
||||
do {
|
||||
any = false;
|
||||
assetentry_t *entry = ASSET.entries;
|
||||
do {
|
||||
if(entry->type == ASSET_LOADER_TYPE_NULL) { entry++; continue; }
|
||||
if(entry->state != ASSET_ENTRY_STATE_LOADED) { entry++; continue; }
|
||||
if(entry->refs.count > 0) { entry++; continue; }
|
||||
errorChain(assetEntryDispose(entry));
|
||||
any = true;
|
||||
entry++;
|
||||
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
} while(any);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetUpdate(void) {
|
||||
assertIsMainThread("assetUpdate must be called from the main thread.");
|
||||
|
||||
@@ -288,7 +338,9 @@ errorret_t assetUpdate(void) {
|
||||
} else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) {
|
||||
assetentry_t *loadedEntry = loading->entry;
|
||||
loading->entry = NULL;
|
||||
eventInvoke(&loadedEntry->onLoaded, loadedEntry);
|
||||
if(loadedEntry->onLoaded) {
|
||||
loadedEntry->onLoaded(loadedEntry, loadedEntry->onLoadedUser);
|
||||
}
|
||||
}
|
||||
|
||||
loading++;
|
||||
@@ -312,8 +364,8 @@ errorret_t assetUpdate(void) {
|
||||
assetentry_t *errEntry = loading->entry;
|
||||
loading->entry = NULL;
|
||||
threadMutexUnlock(&loading->mutex);
|
||||
eventInvoke(&errEntry->onError, errEntry);
|
||||
errorThrow("Failed to load asset asynchronously.");
|
||||
if(errEntry->onError) errEntry->onError(errEntry, errEntry->onErrorUser);
|
||||
loading++;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -324,30 +376,6 @@ errorret_t assetUpdate(void) {
|
||||
}
|
||||
} while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX);
|
||||
|
||||
|
||||
// Reap unused entries.
|
||||
entry = ASSET.entries;
|
||||
do {
|
||||
if(entry->state != ASSET_ENTRY_STATE_LOADED) {
|
||||
entry++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(entry->type == ASSET_LOADER_TYPE_NULL) {
|
||||
entry++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(entry->refs.count > 0) {
|
||||
entry++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// consolePrint("Reaping asset %s", entry->name);
|
||||
errorChain(assetEntryDispose(entry));
|
||||
entry++;
|
||||
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
@@ -413,35 +441,23 @@ errorret_t assetDispose(void) {
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
threadStop(&ASSET.loadThread);
|
||||
|
||||
// Drain-dispose: repeatedly find and dispose zero-ref LOADED entries
|
||||
// until none remain. This handles dependency chains where an entry
|
||||
// (e.g. a model) holds refs on child entries (mesh, texture): dispose
|
||||
// parents first so child ref counts drop to zero, then pick up the
|
||||
// children on the next pass. Without this, a forward-only scan fails
|
||||
// when a shared child entry appears before a parent that still holds a
|
||||
// ref to it.
|
||||
bool_t any;
|
||||
do {
|
||||
any = false;
|
||||
assetentry_t *e = ASSET.entries;
|
||||
do {
|
||||
if(e->type == ASSET_LOADER_TYPE_NULL) { e++; continue; }
|
||||
if(e->state != ASSET_ENTRY_STATE_LOADED) { e++; continue; }
|
||||
if(e->refs.count > 0) { e++; continue; }
|
||||
errorChain(assetEntryDispose(e));
|
||||
any = true;
|
||||
e++;
|
||||
} while(e < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
} while(any);
|
||||
errorChain(assetReapUnused());
|
||||
|
||||
// Cleanup zip file.
|
||||
// Cleanup zip files.
|
||||
if(ASSET.zip != NULL) {
|
||||
if(zip_close(ASSET.zip) != 0) {
|
||||
errorThrow("Failed to close asset zip archive.");
|
||||
errorThrow("Failed to close compressed asset zip archive.");
|
||||
}
|
||||
ASSET.zip = NULL;
|
||||
}
|
||||
if(ASSET.zipStored != NULL) {
|
||||
if(zip_close(ASSET.zipStored) != 0) {
|
||||
errorThrow("Failed to close stored asset zip archive.");
|
||||
}
|
||||
ASSET.zipStored = NULL;
|
||||
}
|
||||
|
||||
errorChain(assetDisposePlatform());
|
||||
threadMutexDispose(&ASSET.zipLock);
|
||||
errorOk();
|
||||
}
|
||||
+33
-2
@@ -23,13 +23,34 @@
|
||||
#define ASSET_FILE_NAME "dusk.dsk"
|
||||
#define ASSET_HEADER_SIZE 3
|
||||
|
||||
#define ASSET_LOADING_COUNT_MAX 20
|
||||
#define ASSET_ENTRY_COUNT_MAX 128
|
||||
#define ASSET_LOADING_COUNT_MAX 10
|
||||
#define ASSET_ENTRY_COUNT_MAX 64
|
||||
|
||||
typedef struct asset_s {
|
||||
// Compressed (DEFLATE) archive - expected to hold the bulk of a game's
|
||||
// binary assets. Looked up first by assetFileInit().
|
||||
zip_t *zip;
|
||||
|
||||
// Stored (uncompressed) archive - expected to hold small files that need
|
||||
// reliable repeated seeking/re-opening (e.g. locale strings), which
|
||||
// libzip only supports reliably for uncompressed entries. Looked up by
|
||||
// assetFileInit() only if the name isn't found in `zip`.
|
||||
zip_t *zipStored;
|
||||
|
||||
assetplatform_t platform;
|
||||
|
||||
// Guards every libzip call against ASSET.zip/ASSET.zipStored (zip_fopen,
|
||||
// zip_fread, zip_fclose, zip_fseek, zip_stat, zip_name_locate - see
|
||||
// assetfile.c/assetFileExists). libzip is documented as not thread-safe,
|
||||
// and this asset system genuinely calls it from multiple real threads at
|
||||
// once (main thread, the background load thread below, and - on
|
||||
// platforms that stream PCM from an asset on their own thread, like PSP
|
||||
// - an audio feeder thread too). Confirmed necessary on real PSP
|
||||
// hardware: without this lock, concurrent zip_fopen/zip_fread calls from
|
||||
// different threads corrupted reads (EINVAL, then zlib data errors) once
|
||||
// dusk.dsk stopped being read entirely into memory up front.
|
||||
threadmutex_t zipLock;
|
||||
|
||||
// Background loading thread.
|
||||
thread_t loadThread;
|
||||
|
||||
@@ -112,6 +133,16 @@ void assetUnlock(const char_t *name);
|
||||
*/
|
||||
void assetUnlockEntry(assetentry_t *entry);
|
||||
|
||||
/**
|
||||
* Frees every currently unreferenced (zero-ref) loaded asset entry. Repeats
|
||||
* until a full pass frees nothing further, since disposing a parent entry
|
||||
* (e.g. a model) may drop a child entry's (e.g. a mesh) ref count to zero,
|
||||
* making it eligible for reaping too.
|
||||
*
|
||||
* @return An error code if any entry could not be disposed properly.
|
||||
*/
|
||||
errorret_t assetReapUnused(void);
|
||||
|
||||
/**
|
||||
* Requires an asset entry to be loaded. This will block until the asset entry
|
||||
* is fully loaded.
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "assetbatch.h"
|
||||
#include "asset.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
#include <unistd.h>
|
||||
|
||||
void assetBatchInit(
|
||||
assetbatch_t *batch,
|
||||
const uint16_t count,
|
||||
const assetbatchdesc_t *descs
|
||||
) {
|
||||
assertNotNull(batch, "Batch cannot be NULL.");
|
||||
assertNotNull(descs, "Descs cannot be NULL.");
|
||||
assertTrue(count > 0, "Count must be greater than 0.");
|
||||
assertTrue(
|
||||
count <= ASSET_BATCH_COUNT_MAX, "Count exceeds ASSET_BATCH_COUNT_MAX."
|
||||
);
|
||||
|
||||
memoryZero(batch, sizeof(assetbatch_t));
|
||||
batch->count = count;
|
||||
|
||||
eventInit(
|
||||
&batch->onLoaded,
|
||||
batch->onLoadedCallbacks, batch->onLoadedUsers, ASSET_BATCH_EVENT_MAX
|
||||
);
|
||||
eventInit(
|
||||
&batch->onEntryLoaded,
|
||||
batch->onEntryLoadedCallbacks,
|
||||
batch->onEntryLoadedUsers,
|
||||
ASSET_BATCH_EVENT_MAX
|
||||
);
|
||||
eventInit(
|
||||
&batch->onError,
|
||||
batch->onErrorCallbacks, batch->onErrorUsers, ASSET_BATCH_EVENT_MAX
|
||||
);
|
||||
eventInit(
|
||||
&batch->onEntryError,
|
||||
batch->onEntryErrorCallbacks,
|
||||
batch->onEntryErrorUsers,
|
||||
ASSET_BATCH_EVENT_MAX
|
||||
);
|
||||
|
||||
for(uint16_t i = 0; i < count; i++) {
|
||||
batch->inputs[i] = descs[i].input;
|
||||
batch->entries[i] = assetLock(
|
||||
descs[i].path, descs[i].type, &batch->inputs[i]
|
||||
);
|
||||
|
||||
if(batch->entries[i]->state == ASSET_ENTRY_STATE_LOADED) {
|
||||
// Already loaded (cached) - count it now, no subscription needed.
|
||||
batch->loadedCount++;
|
||||
} else if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) {
|
||||
batch->errorCount++;
|
||||
} else {
|
||||
eventSubscribe(
|
||||
&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb, batch
|
||||
);
|
||||
eventSubscribe(
|
||||
&batch->entries[i]->onError, assetBatchEntryOnErrorCb, batch
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void assetBatchLock(assetbatch_t *batch) {
|
||||
assertNotNull(batch, "Batch cannot be NULL.");
|
||||
for(uint16_t i = 0; i < batch->count; i++) {
|
||||
assetEntryLock(batch->entries[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void assetBatchUnlock(assetbatch_t *batch) {
|
||||
assertNotNull(batch, "Batch cannot be NULL.");
|
||||
for(uint16_t i = 0; i < batch->count; i++) {
|
||||
assetEntryUnlock(batch->entries[i]);
|
||||
}
|
||||
}
|
||||
|
||||
bool_t assetBatchIsLoaded(const assetbatch_t *batch) {
|
||||
assertNotNull(batch, "Batch cannot be NULL.");
|
||||
for(uint16_t i = 0; i < batch->count; i++) {
|
||||
if(batch->entries[i]->state != ASSET_ENTRY_STATE_LOADED) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool_t assetBatchHasError(const assetbatch_t *batch) {
|
||||
assertNotNull(batch, "Batch cannot be NULL.");
|
||||
for(uint16_t i = 0; i < batch->count; i++) {
|
||||
if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
errorret_t assetBatchRequireLoaded(assetbatch_t *batch) {
|
||||
assertNotNull(batch, "Batch cannot be NULL.");
|
||||
|
||||
bool_t allDone;
|
||||
do {
|
||||
allDone = true;
|
||||
for(uint16_t i = 0; i < batch->count; i++) {
|
||||
const assetentrystate_t state = batch->entries[i]->state;
|
||||
if(state == ASSET_ENTRY_STATE_ERROR) {
|
||||
errorThrow("Asset '%s' failed to load.", batch->entries[i]->name);
|
||||
}
|
||||
if(state != ASSET_ENTRY_STATE_LOADED) {
|
||||
allDone = false;
|
||||
}
|
||||
}
|
||||
if(!allDone) {
|
||||
usleep(1000);
|
||||
errorChain(assetUpdate());
|
||||
}
|
||||
} while(!allDone);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
void assetBatchDispose(assetbatch_t *batch) {
|
||||
assertNotNull(batch, "Batch cannot be NULL.");
|
||||
for(uint16_t i = 0; i < batch->count; i++) {
|
||||
if(batch->entries[i]) {
|
||||
// Unsubscribe while we still hold a lock so the entry is live.
|
||||
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb);
|
||||
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb);
|
||||
assetUnlockEntry(batch->entries[i]);
|
||||
}
|
||||
}
|
||||
memoryZero(batch, sizeof(assetbatch_t));
|
||||
}
|
||||
|
||||
void assetBatchEntryOnLoadedCb(void *params, void *user) {
|
||||
assetentry_t *entry = (assetentry_t *)params;
|
||||
assetbatch_t *batch = (assetbatch_t *)user;
|
||||
|
||||
batch->loadedCount++;
|
||||
eventInvoke(&batch->onEntryLoaded, entry);
|
||||
|
||||
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
|
||||
if(batch->errorCount == 0) {
|
||||
eventInvoke(&batch->onLoaded, batch);
|
||||
} else {
|
||||
eventInvoke(&batch->onError, batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void assetBatchEntryOnErrorCb(void *params, void *user) {
|
||||
assetentry_t *entry = (assetentry_t *)params;
|
||||
assetbatch_t *batch = (assetbatch_t *)user;
|
||||
|
||||
batch->errorCount++;
|
||||
eventInvoke(&batch->onEntryError, entry);
|
||||
|
||||
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
|
||||
eventInvoke(&batch->onError, batch);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "asset/loader/assetentry.h"
|
||||
#include "asset/loader/assetloader.h"
|
||||
#include "event/event.h"
|
||||
|
||||
#define ASSET_BATCH_COUNT_MAX 64
|
||||
#define ASSET_BATCH_EVENT_MAX 4
|
||||
|
||||
typedef struct {
|
||||
const char_t *path;
|
||||
assetloadertype_t type;
|
||||
assetloaderinput_t input;
|
||||
} assetbatchdesc_t;
|
||||
|
||||
typedef struct {
|
||||
assetentry_t *entries[ASSET_BATCH_COUNT_MAX];
|
||||
assetloaderinput_t inputs[ASSET_BATCH_COUNT_MAX];
|
||||
uint16_t count;
|
||||
uint16_t loadedCount;
|
||||
uint16_t errorCount;
|
||||
|
||||
/** Fires once when every entry loaded. params = assetbatch_t * */
|
||||
event_t onLoaded;
|
||||
eventcallback_t onLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
|
||||
void *onLoadedUsers[ASSET_BATCH_EVENT_MAX];
|
||||
|
||||
/** Fires each time a single entry loads. params = assetentry_t * */
|
||||
event_t onEntryLoaded;
|
||||
eventcallback_t onEntryLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
|
||||
void *onEntryLoadedUsers[ASSET_BATCH_EVENT_MAX];
|
||||
|
||||
/** Fires when all entries finish (any with errors). params: assetbatch_t * */
|
||||
event_t onError;
|
||||
eventcallback_t onErrorCallbacks[ASSET_BATCH_EVENT_MAX];
|
||||
void *onErrorUsers[ASSET_BATCH_EVENT_MAX];
|
||||
|
||||
/** Fires each time a single entry errors. params = assetentry_t * */
|
||||
event_t onEntryError;
|
||||
eventcallback_t onEntryErrorCallbacks[ASSET_BATCH_EVENT_MAX];
|
||||
void *onEntryErrorUsers[ASSET_BATCH_EVENT_MAX];
|
||||
} assetbatch_t;
|
||||
|
||||
/**
|
||||
* Initialises the batch from an array of descriptors. Each entry is locked
|
||||
* and queued for loading immediately.
|
||||
*
|
||||
* @param batch Batch to initialise.
|
||||
* @param descs Array of entry descriptors (need not outlive this call).
|
||||
* @param count Number of descriptors (must be <= ASSET_BATCH_COUNT_MAX).
|
||||
*/
|
||||
void assetBatchInit(
|
||||
assetbatch_t *batch,
|
||||
uint16_t count,
|
||||
const assetbatchdesc_t *descs
|
||||
);
|
||||
|
||||
/**
|
||||
* Acquires one additional lock on every entry in the batch.
|
||||
*
|
||||
* @param batch Batch to lock.
|
||||
*/
|
||||
void assetBatchLock(assetbatch_t *batch);
|
||||
|
||||
/**
|
||||
* Releases one lock from every entry in the batch. When an entry's lock
|
||||
* count reaches zero it will be reaped on the next assetUpdate.
|
||||
*
|
||||
* @param batch Batch to unlock.
|
||||
*/
|
||||
void assetBatchUnlock(assetbatch_t *batch);
|
||||
|
||||
/**
|
||||
* Returns true if every entry in the batch has finished loading.
|
||||
*
|
||||
* @param batch Batch to query.
|
||||
*/
|
||||
bool_t assetBatchIsLoaded(const assetbatch_t *batch);
|
||||
|
||||
/**
|
||||
* Returns true if any entry in the batch is in an error state.
|
||||
*
|
||||
* @param batch Batch to query.
|
||||
*/
|
||||
bool_t assetBatchHasError(const assetbatch_t *batch);
|
||||
|
||||
/**
|
||||
* Blocks until every entry is loaded. Returns an error if any entry fails.
|
||||
*
|
||||
* @param batch Batch to wait on.
|
||||
*/
|
||||
errorret_t assetBatchRequireLoaded(assetbatch_t *batch);
|
||||
|
||||
/**
|
||||
* Releases the batch's lock on every entry and clears the batch. After this
|
||||
* call the batch struct may be reused with assetBatchInit.
|
||||
*
|
||||
* @param batch Batch to dispose.
|
||||
*/
|
||||
void assetBatchDispose(assetbatch_t *batch);
|
||||
|
||||
/**
|
||||
* Event trampoline invoked when a batch entry finishes loading.
|
||||
* Increments the loaded counter and fires batch-level events.
|
||||
*
|
||||
* @param params The loaded assetentry_t pointer.
|
||||
* @param user The owning assetbatch_t pointer.
|
||||
*/
|
||||
void assetBatchEntryOnLoadedCb(void *params, void *user);
|
||||
|
||||
/**
|
||||
* Event trampoline invoked when a batch entry fails to load.
|
||||
* Increments the error counter and fires batch-level events.
|
||||
*
|
||||
* @param params The errored assetentry_t pointer.
|
||||
* @param user The owning assetbatch_t pointer.
|
||||
*/
|
||||
void assetBatchEntryOnErrorCb(void *params, void *user);
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "assetdsk.h"
|
||||
#include "util/memory.h"
|
||||
#include "util/endian.h"
|
||||
#include "assert/assert.h"
|
||||
#include <zlib.h>
|
||||
|
||||
errorret_t assetDskParseHeader(
|
||||
const uint8_t *bytes,
|
||||
const size_t bytesSize,
|
||||
assetdskheader_t *outHeader
|
||||
) {
|
||||
assertNotNull(bytes, "Bytes cannot be NULL.");
|
||||
assertNotNull(outHeader, "Out header cannot be NULL.");
|
||||
|
||||
if(bytesSize < ASSET_DSK_HEADER_SIZE) {
|
||||
errorThrow("dusk.dsk header is truncated.");
|
||||
}
|
||||
|
||||
if(memoryCompare(bytes, ASSET_DSK_MAGIC, ASSET_DSK_MAGIC_SIZE) != 0) {
|
||||
errorThrow("dusk.dsk has an invalid magic header.");
|
||||
}
|
||||
|
||||
// Every field is a little-endian uint32_t regardless of host - convert
|
||||
// to host order (a no-op on little-endian hosts, a real byteswap on
|
||||
// Dolphin's big-endian PowerPC).
|
||||
uint32_t fields[7];
|
||||
memoryCopy(fields, bytes + ASSET_DSK_MAGIC_SIZE, sizeof(fields));
|
||||
for(uint8_t i = 0; i < 7; i++) {
|
||||
fields[i] = endianLittleToHost32(fields[i]);
|
||||
}
|
||||
|
||||
const uint32_t version = fields[0];
|
||||
if(version != ASSET_DSK_VERSION) {
|
||||
errorThrow("dusk.dsk has an unsupported version: %u", version);
|
||||
}
|
||||
|
||||
outHeader->compressedOffset = fields[1];
|
||||
outHeader->compressedSize = fields[2];
|
||||
outHeader->compressedChecksum = fields[3];
|
||||
outHeader->storedOffset = fields[4];
|
||||
outHeader->storedSize = fields[5];
|
||||
outHeader->storedChecksum = fields[6];
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetDskOpenFromPath(
|
||||
const char_t *path,
|
||||
zip_t **outCompressed,
|
||||
zip_t **outStored
|
||||
) {
|
||||
errorChain(assetDskOpenFromPathRange(
|
||||
path, 0, SIZE_MAX, outCompressed, outStored
|
||||
));
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetDskOpenFromPathRange(
|
||||
const char_t *path,
|
||||
const size_t baseOffset,
|
||||
const size_t baseSize,
|
||||
zip_t **outCompressed,
|
||||
zip_t **outStored
|
||||
) {
|
||||
assertNotNull(path, "Path cannot be NULL.");
|
||||
assertNotNull(outCompressed, "Out compressed cannot be NULL.");
|
||||
assertNotNull(outStored, "Out stored cannot be NULL.");
|
||||
|
||||
*outCompressed = NULL;
|
||||
*outStored = NULL;
|
||||
|
||||
FILE *headerFile = fopen(path, "rb");
|
||||
if(headerFile == NULL) {
|
||||
errorThrow("Failed to open dusk.dsk: %s", path);
|
||||
}
|
||||
|
||||
if(fseek(headerFile, (long) baseOffset, SEEK_SET) != 0) {
|
||||
fclose(headerFile);
|
||||
errorThrow("Failed to seek to dusk.dsk range in file: %s", path);
|
||||
}
|
||||
|
||||
uint8_t headerBytes[ASSET_DSK_HEADER_SIZE];
|
||||
size_t headerRead = fread(headerBytes, 1, sizeof(headerBytes), headerFile);
|
||||
fclose(headerFile);
|
||||
if(headerRead != sizeof(headerBytes)) {
|
||||
errorThrow("Failed to read dusk.dsk header: %s", path);
|
||||
}
|
||||
|
||||
assetdskheader_t header;
|
||||
errorChain(assetDskParseHeader(headerBytes, sizeof(headerBytes), &header));
|
||||
|
||||
if(
|
||||
(size_t) header.compressedOffset + header.compressedSize > baseSize ||
|
||||
(size_t) header.storedOffset + header.storedSize > baseSize
|
||||
) {
|
||||
errorThrow("dusk.dsk header describes ranges beyond its containing file.");
|
||||
}
|
||||
|
||||
zip_error_t zipError;
|
||||
zip_error_init(&zipError);
|
||||
|
||||
zip_source_t *compressedSource = zip_source_file_create(
|
||||
path,
|
||||
(zip_uint64_t) (baseOffset + header.compressedOffset),
|
||||
(zip_int64_t) header.compressedSize,
|
||||
&zipError
|
||||
);
|
||||
if(compressedSource == NULL) {
|
||||
errorThrow(
|
||||
"Failed to create compressed dusk.dsk source: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
*outCompressed = zip_open_from_source(compressedSource, ZIP_RDONLY, &zipError);
|
||||
if(*outCompressed == NULL) {
|
||||
zip_source_free(compressedSource);
|
||||
errorThrow(
|
||||
"Failed to open compressed dusk.dsk archive: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
zip_source_t *storedSource = zip_source_file_create(
|
||||
path,
|
||||
(zip_uint64_t) (baseOffset + header.storedOffset),
|
||||
(zip_int64_t) header.storedSize,
|
||||
&zipError
|
||||
);
|
||||
if(storedSource == NULL) {
|
||||
zip_close(*outCompressed);
|
||||
*outCompressed = NULL;
|
||||
errorThrow(
|
||||
"Failed to create stored dusk.dsk source: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
*outStored = zip_open_from_source(storedSource, ZIP_RDONLY, &zipError);
|
||||
if(*outStored == NULL) {
|
||||
zip_source_free(storedSource);
|
||||
zip_close(*outCompressed);
|
||||
*outCompressed = NULL;
|
||||
errorThrow(
|
||||
"Failed to open stored dusk.dsk archive: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
// The stored archive is small by convention, so verifying its checksum
|
||||
// here (one extra small read) is cheap; the compressed archive isn't
|
||||
// checked since it's meant to be read lazily/on-demand from here on.
|
||||
uint8_t *storedBytes = (uint8_t *) memoryAllocate(header.storedSize);
|
||||
FILE *storedFile = fopen(path, "rb");
|
||||
if(storedFile == NULL) {
|
||||
memoryFree(storedBytes);
|
||||
zip_close(*outStored);
|
||||
zip_close(*outCompressed);
|
||||
*outStored = NULL;
|
||||
*outCompressed = NULL;
|
||||
errorThrow("Failed to re-open dusk.dsk to verify stored checksum: %s", path);
|
||||
}
|
||||
fseek(storedFile, (long) (baseOffset + header.storedOffset), SEEK_SET);
|
||||
size_t storedRead = fread(storedBytes, 1, header.storedSize, storedFile);
|
||||
fclose(storedFile);
|
||||
if(storedRead != header.storedSize) {
|
||||
memoryFree(storedBytes);
|
||||
zip_close(*outStored);
|
||||
zip_close(*outCompressed);
|
||||
*outStored = NULL;
|
||||
*outCompressed = NULL;
|
||||
errorThrow("Failed to read dusk.dsk stored archive to verify checksum: %s", path);
|
||||
}
|
||||
|
||||
uint32_t checksum = (uint32_t) crc32(0L, storedBytes, (uInt) header.storedSize);
|
||||
memoryFree(storedBytes);
|
||||
if(checksum != header.storedChecksum) {
|
||||
zip_close(*outStored);
|
||||
zip_close(*outCompressed);
|
||||
*outStored = NULL;
|
||||
*outCompressed = NULL;
|
||||
errorThrow("dusk.dsk stored archive failed checksum verification: %s", path);
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetDskOpenFromBuffer(
|
||||
uint8_t *buffer,
|
||||
const size_t bufferSize,
|
||||
zip_t **outCompressed,
|
||||
zip_t **outStored
|
||||
) {
|
||||
assertNotNull(buffer, "Buffer cannot be NULL.");
|
||||
assertNotNull(outCompressed, "Out compressed cannot be NULL.");
|
||||
assertNotNull(outStored, "Out stored cannot be NULL.");
|
||||
|
||||
*outCompressed = NULL;
|
||||
*outStored = NULL;
|
||||
|
||||
assetdskheader_t header;
|
||||
errorChain(assetDskParseHeader(buffer, bufferSize, &header));
|
||||
|
||||
if(
|
||||
(size_t) header.compressedOffset + header.compressedSize > bufferSize ||
|
||||
(size_t) header.storedOffset + header.storedSize > bufferSize
|
||||
) {
|
||||
errorThrow("dusk.dsk header describes ranges beyond the buffer.");
|
||||
}
|
||||
|
||||
uint32_t compressedChecksum = (uint32_t) crc32(
|
||||
0L, buffer + header.compressedOffset, (uInt) header.compressedSize
|
||||
);
|
||||
if(compressedChecksum != header.compressedChecksum) {
|
||||
errorThrow("dusk.dsk compressed archive failed checksum verification.");
|
||||
}
|
||||
|
||||
uint32_t storedChecksum = (uint32_t) crc32(
|
||||
0L, buffer + header.storedOffset, (uInt) header.storedSize
|
||||
);
|
||||
if(storedChecksum != header.storedChecksum) {
|
||||
errorThrow("dusk.dsk stored archive failed checksum verification.");
|
||||
}
|
||||
|
||||
zip_error_t zipError;
|
||||
zip_error_init(&zipError);
|
||||
|
||||
// freep=0 for both - they're non-owning windows into the same caller-owned
|
||||
// buffer, not independent allocations libzip should free.
|
||||
zip_source_t *compressedSource = zip_source_buffer_create(
|
||||
buffer + header.compressedOffset, header.compressedSize, 0, &zipError
|
||||
);
|
||||
if(compressedSource == NULL) {
|
||||
errorThrow(
|
||||
"Failed to create compressed dusk.dsk source: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
*outCompressed = zip_open_from_source(compressedSource, ZIP_RDONLY, &zipError);
|
||||
if(*outCompressed == NULL) {
|
||||
zip_source_free(compressedSource);
|
||||
errorThrow(
|
||||
"Failed to open compressed dusk.dsk archive: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
zip_source_t *storedSource = zip_source_buffer_create(
|
||||
buffer + header.storedOffset, header.storedSize, 0, &zipError
|
||||
);
|
||||
if(storedSource == NULL) {
|
||||
zip_close(*outCompressed);
|
||||
*outCompressed = NULL;
|
||||
errorThrow(
|
||||
"Failed to create stored dusk.dsk source: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
*outStored = zip_open_from_source(storedSource, ZIP_RDONLY, &zipError);
|
||||
if(*outStored == NULL) {
|
||||
zip_source_free(storedSource);
|
||||
zip_close(*outCompressed);
|
||||
*outCompressed = NULL;
|
||||
errorThrow(
|
||||
"Failed to open stored dusk.dsk archive: %s", zip_error_strerror(&zipError)
|
||||
);
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
#include <zip.h>
|
||||
|
||||
// "DSK2" - distinct from a plain zip's "PK\x03\x04" so a stray plain zip
|
||||
// never gets misread as a valid dusk.dsk.
|
||||
#define ASSET_DSK_MAGIC_SIZE 4
|
||||
#define ASSET_DSK_MAGIC "DSK2"
|
||||
#define ASSET_DSK_VERSION 1
|
||||
|
||||
// magic(4) + version(4) + compressedOffset(4) + compressedSize(4) +
|
||||
// compressedChecksum(4) + storedOffset(4) + storedSize(4) +
|
||||
// storedChecksum(4), all little-endian regardless of host - see
|
||||
// assetDskParseHeader.
|
||||
#define ASSET_DSK_HEADER_SIZE 32
|
||||
|
||||
/**
|
||||
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is two independent, back
|
||||
* to back zip archives (see tools/asset/pack) rather than a single plain
|
||||
* zip: a "compressed" one (DEFLATE, expected to hold the bulk of a game's
|
||||
* binary assets, opened lazily/on-demand) and a "stored" one (uncompressed,
|
||||
* expected to hold small files - locale strings, config - that need
|
||||
* reliable repeated seeking/re-opening, which libzip only supports for
|
||||
* uncompressed entries).
|
||||
*/
|
||||
typedef struct {
|
||||
uint32_t compressedOffset;
|
||||
uint32_t compressedSize;
|
||||
uint32_t compressedChecksum;
|
||||
uint32_t storedOffset;
|
||||
uint32_t storedSize;
|
||||
uint32_t storedChecksum;
|
||||
} assetdskheader_t;
|
||||
|
||||
/**
|
||||
* Parses a DSK2 header from a raw byte buffer (at least
|
||||
* ASSET_DSK_HEADER_SIZE bytes), validating the magic/version and
|
||||
* byte-swapping the little-endian fields to host order.
|
||||
*
|
||||
* @param bytes Buffer containing the header (and beyond).
|
||||
* @param bytesSize Number of bytes available at `bytes`.
|
||||
* @param outHeader Filled with the parsed header on success.
|
||||
* @return OK on success, error if too short, bad magic, or unsupported version.
|
||||
*/
|
||||
errorret_t assetDskParseHeader(
|
||||
const uint8_t *bytes,
|
||||
const size_t bytesSize,
|
||||
assetdskheader_t *outHeader
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens both archives of a dusk.dsk file given a filesystem path, using
|
||||
* lazy/windowed file-backed zip sources - no more memory used than the
|
||||
* existing per-platform small read buffers, matching the memory
|
||||
* characteristics of a plain zip_open() on the whole file. Verifies the
|
||||
* (small, by convention) stored archive's checksum; the compressed
|
||||
* archive's checksum is intentionally not verified here since doing so
|
||||
* would require reading the bulk of the game's assets just to compute it.
|
||||
*
|
||||
* See assetDskOpenFromPathRange() for the case where the DSK2 blob isn't
|
||||
* the whole file (e.g. embedded inside another container).
|
||||
*
|
||||
* @param path Filesystem path to the dusk.dsk file.
|
||||
* @param outCompressed Set to the opened compressed archive on success.
|
||||
* @param outStored Set to the opened stored archive on success.
|
||||
* @return OK on success, error if the file is missing, too short, has a
|
||||
* bad header, or either archive fails to open/verify.
|
||||
*/
|
||||
errorret_t assetDskOpenFromPath(
|
||||
const char_t *path,
|
||||
zip_t **outCompressed,
|
||||
zip_t **outStored
|
||||
);
|
||||
|
||||
/**
|
||||
* Same as assetDskOpenFromPath(), but the DSK2 blob doesn't start at the
|
||||
* beginning of `path` - it's a byte range embedded inside a larger
|
||||
* container file (e.g. the PSAR region of an EBOOT.PBP). Every offset the
|
||||
* header describes is relative to `baseOffset`; `baseSize` bounds them
|
||||
* (the size of the embedded blob, not the whole container file) - pass
|
||||
* SIZE_MAX to skip that check when the caller doesn't know/care.
|
||||
*
|
||||
* @param path Filesystem path to the container file.
|
||||
* @param baseOffset Byte offset within `path` where the DSK2 blob starts.
|
||||
* @param baseSize Number of bytes available at `baseOffset`.
|
||||
* @param outCompressed Set to the opened compressed archive on success.
|
||||
* @param outStored Set to the opened stored archive on success.
|
||||
* @return OK on success, error if the file is missing, too short, has a
|
||||
* bad header, or either archive fails to open/verify.
|
||||
*/
|
||||
errorret_t assetDskOpenFromPathRange(
|
||||
const char_t *path,
|
||||
const size_t baseOffset,
|
||||
const size_t baseSize,
|
||||
zip_t **outCompressed,
|
||||
zip_t **outStored
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens both archives of a dusk.dsk file already fully resident in memory
|
||||
* (e.g. a PSAR embedded in an EBOOT.PBP, or an ISO-embedded file already
|
||||
* read via DVD_ReadAbs - platforms that already buffer the whole file for
|
||||
* reasons unrelated to this format). Neither archive takes ownership of
|
||||
* `buffer` (both are opened as non-owning sub-ranges of it) - the caller
|
||||
* remains responsible for freeing it, and must keep it alive for as long
|
||||
* as either archive stays open. Verifies both archives' checksums, since
|
||||
* the bytes are already resident.
|
||||
*
|
||||
* @param buffer The whole dusk.dsk file's bytes.
|
||||
* @param bufferSize Number of bytes at `buffer`.
|
||||
* @param outCompressed Set to the opened compressed archive on success.
|
||||
* @param outStored Set to the opened stored archive on success.
|
||||
* @return OK on success, error if too short, has a bad header/checksum, or
|
||||
* either archive fails to open.
|
||||
*/
|
||||
errorret_t assetDskOpenFromBuffer(
|
||||
uint8_t *buffer,
|
||||
const size_t bufferSize,
|
||||
zip_t **outCompressed,
|
||||
zip_t **outStored
|
||||
);
|
||||
+80
-14
@@ -24,9 +24,20 @@ errorret_t assetFileInit(
|
||||
file->params = params;
|
||||
file->output = output;
|
||||
|
||||
// Stat the file
|
||||
// Stat the file, trying the compressed archive first and falling back to
|
||||
// the stored one - remember which matched so assetFileOpen opens it from
|
||||
// the right archive.
|
||||
zip_stat_init(&file->stat);
|
||||
if(!zip_stat(ASSET.zip, filename, 0, &file->stat) == 0) {
|
||||
threadMutexLock(&ASSET.zipLock);
|
||||
if(zip_stat(ASSET.zip, filename, 0, &file->stat) == 0) {
|
||||
file->sourceZip = ASSET.zip;
|
||||
} else if(zip_stat(ASSET.zipStored, filename, 0, &file->stat) == 0) {
|
||||
file->sourceZip = ASSET.zipStored;
|
||||
} else {
|
||||
file->sourceZip = NULL;
|
||||
}
|
||||
threadMutexUnlock(&ASSET.zipLock);
|
||||
if(file->sourceZip == NULL) {
|
||||
errorThrow("Failed to stat asset file: %s", filename);
|
||||
}
|
||||
|
||||
@@ -47,6 +58,25 @@ errorret_t assetFileRewind(assetfile_t *file) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
// Prefer seeking within the still-open handle over closing and
|
||||
// re-opening it. Repeatedly closing/re-opening the same compressed zip
|
||||
// entry (once per rewind - e.g. once per locale string lookup) was
|
||||
// confirmed unreliable on at least one platform's zip backend, silently
|
||||
// skipping ranges of the decompressed content on some re-opens after
|
||||
// the first. A real seek avoids that failure mode entirely, with no
|
||||
// extra memory cost over the close+reopen fallback.
|
||||
threadMutexLock(&ASSET.zipLock);
|
||||
bool_t seekable = zip_file_is_seekable(file->zipFile);
|
||||
int seekResult = seekable ? zip_fseek(file->zipFile, 0, SEEK_SET) : -1;
|
||||
threadMutexUnlock(&ASSET.zipLock);
|
||||
if(seekable) {
|
||||
if(seekResult != 0) {
|
||||
errorThrow("Failed to seek asset file: %s", file->filename);
|
||||
}
|
||||
file->position = 0;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorChain(assetFileClose(file));
|
||||
errorChain(assetFileOpen(file));
|
||||
errorOk();
|
||||
@@ -55,10 +85,12 @@ errorret_t assetFileRewind(assetfile_t *file) {
|
||||
errorret_t assetFileOpen(assetfile_t *file) {
|
||||
assertNotNull(file, "Asset file cannot be NULL.");
|
||||
assertNotNull(file->filename, "Asset file filename cannot be NULL.");
|
||||
assertNotNull(ASSET.zip, "Asset zip cannot be NULL.");
|
||||
assertNotNull(file->sourceZip, "Asset file must be inited before opening.");
|
||||
assertNull(file->zipFile, "Asset file already open.");
|
||||
|
||||
file->zipFile = zip_fopen(ASSET.zip, file->filename, 0);
|
||||
threadMutexLock(&ASSET.zipLock);
|
||||
file->zipFile = zip_fopen(file->sourceZip, file->filename, 0);
|
||||
threadMutexUnlock(&ASSET.zipLock);
|
||||
if(file->zipFile == NULL) {
|
||||
errorThrow("Failed to open asset file: %s", file->filename);
|
||||
}
|
||||
@@ -79,21 +111,52 @@ errorret_t assetFileRead(
|
||||
uint8_t tempBuffer[256];
|
||||
while(bytesRemaining > 0) {
|
||||
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer));
|
||||
// The recursive call below already advances file->position by
|
||||
// chunkSize (the non-NULL branch does this itself) - do not also
|
||||
// advance it here, or every skip ends up double-counted.
|
||||
errorChain(assetFileRead(file, tempBuffer, chunkSize));
|
||||
file->position += chunkSize;
|
||||
bytesRemaining -= chunkSize;
|
||||
}
|
||||
file->lastRead = bufferSize;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
// I assume zip_fread takes buffer NULL for skipping?
|
||||
zip_int64_t bytesRead = zip_fread(file->zipFile, buffer, bufferSize);
|
||||
// Some zip_fread() implementations (seen on PSP) reject a single call
|
||||
// asking for the entire (potentially large) file at once with EINVAL;
|
||||
// the line reader above only ever asks for up to 1024 bytes per call and
|
||||
// works fine, so read in bounded chunks here too.
|
||||
size_t totalRead = 0;
|
||||
uint8_t *dest = (uint8_t *)buffer;
|
||||
while(totalRead < bufferSize) {
|
||||
size_t chunkSize = mathMin(
|
||||
bufferSize - totalRead, ASSET_FILE_READ_CHUNK_MAX
|
||||
);
|
||||
threadMutexLock(&ASSET.zipLock);
|
||||
zip_int64_t bytesRead = zip_fread(
|
||||
file->zipFile, dest + totalRead, chunkSize
|
||||
);
|
||||
errorret_t readError = errorOkImpl();
|
||||
if(bytesRead < 0) {
|
||||
errorThrow("Failed to read from asset file: %s", file->filename);
|
||||
// Built (not thrown) while still holding the lock, so
|
||||
// zip_file_strerror() reads the just-failed zipFile's error state
|
||||
// before another thread can touch it - errorThrow() itself isn't
|
||||
// used here since it returns immediately, which would leave
|
||||
// ASSET.zipLock held forever.
|
||||
readError = errorThrowImpl(
|
||||
&ERROR_STATE, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
|
||||
"Failed to read from asset file: %s (%s)",
|
||||
file->filename, zip_file_strerror(file->zipFile)
|
||||
);
|
||||
}
|
||||
file->position += bytesRead;
|
||||
file->lastRead = bytesRead;
|
||||
threadMutexUnlock(&ASSET.zipLock);
|
||||
if(errorIsNotOk(readError)) {
|
||||
errorChain(readError);
|
||||
}
|
||||
if(bytesRead == 0) break;
|
||||
totalRead += (size_t)bytesRead;
|
||||
}
|
||||
file->position += totalRead;
|
||||
file->lastRead = totalRead;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
@@ -101,7 +164,10 @@ errorret_t assetFileClose(assetfile_t *file) {
|
||||
assertNotNull(file, "Asset file cannot be NULL.");
|
||||
assertNotNull(file->zipFile, "Asset file must be opened before closing.");
|
||||
|
||||
if(zip_fclose(file->zipFile) != 0) {
|
||||
threadMutexLock(&ASSET.zipLock);
|
||||
int closeResult = zip_fclose(file->zipFile);
|
||||
threadMutexUnlock(&ASSET.zipLock);
|
||||
if(closeResult != 0) {
|
||||
errorThrow("Failed to close asset file: %s", file->filename);
|
||||
}
|
||||
file->zipFile = NULL;
|
||||
@@ -201,7 +267,7 @@ const uint8_t *assetFileLineReaderUnreadPtr(
|
||||
return reader->readBuffer + reader->bufferStart;
|
||||
}
|
||||
|
||||
static errorret_t assetFileLineReaderAppend(
|
||||
errorret_t assetFileLineReaderAppend(
|
||||
assetfilelinereader_t *reader,
|
||||
const uint8_t *src,
|
||||
size_t srcLength
|
||||
@@ -223,7 +289,7 @@ static errorret_t assetFileLineReaderAppend(
|
||||
errorOk();
|
||||
}
|
||||
|
||||
static void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
|
||||
void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
|
||||
assertNotNull(reader, "Reader cannot be NULL.");
|
||||
assertNotNull(reader->outBuffer, "Out buffer cannot be NULL.");
|
||||
assertTrue(
|
||||
@@ -233,7 +299,7 @@ static void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
|
||||
reader->outBuffer[reader->lineLength] = '\0';
|
||||
}
|
||||
|
||||
static ssize_t assetFileLineReaderFindNewline(
|
||||
ssize_t assetFileLineReaderFindNewline(
|
||||
const assetfilelinereader_t *reader
|
||||
) {
|
||||
size_t i;
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
|
||||
#define ASSET_FILE_NAME_MAX 48
|
||||
|
||||
// Max bytes requested per zip_fread() call in assetFileRead(). Some
|
||||
// zip_fread() implementations (seen on PSP) reject a single call asking
|
||||
// for very large amounts of data at once; the locale line reader has
|
||||
// always used 1024-byte reads successfully, so that's the proven-safe cap.
|
||||
#define ASSET_FILE_READ_CHUNK_MAX 1024
|
||||
|
||||
typedef struct assetfile_s assetfile_t;
|
||||
|
||||
typedef errorret_t (*assetfileloader_t)(assetfile_t *file);
|
||||
@@ -26,6 +32,11 @@ typedef struct assetfile_s {
|
||||
zip_int64_t position;
|
||||
zip_int64_t lastRead;
|
||||
zip_file_t *zipFile;
|
||||
|
||||
// The archive this file was found in (ASSET.zip or ASSET.zipStored),
|
||||
// set by assetFileInit and used by assetFileOpen so lookups fall back
|
||||
// correctly between the two dusk.dsk archives.
|
||||
zip_t *sourceZip;
|
||||
} assetfile_t;
|
||||
|
||||
/**
|
||||
@@ -144,6 +155,63 @@ void assetFileLineReaderInit(
|
||||
const size_t outBufferSize
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the number of bytes still unread in the line reader's read buffer.
|
||||
*
|
||||
* @param reader The line reader to check.
|
||||
* @return Number of unread bytes remaining in the read buffer.
|
||||
*/
|
||||
size_t assetFileLineReaderUnreadBytes(const assetfilelinereader_t *reader);
|
||||
|
||||
/**
|
||||
* Returns a pointer to the first unread byte in the line reader's read
|
||||
* buffer.
|
||||
*
|
||||
* @param reader The line reader to check.
|
||||
* @return Pointer to the first unread byte.
|
||||
*/
|
||||
const uint8_t *assetFileLineReaderUnreadPtr(
|
||||
const assetfilelinereader_t *reader
|
||||
);
|
||||
|
||||
/**
|
||||
* Appends src to the line reader's current line buffer, growing lineLength.
|
||||
*
|
||||
* @param reader The line reader whose out buffer to append to.
|
||||
* @param src Bytes to append.
|
||||
* @param srcLength Number of bytes in src.
|
||||
* @return Error state if any (the line would exceed the output buffer).
|
||||
*/
|
||||
errorret_t assetFileLineReaderAppend(
|
||||
assetfilelinereader_t *reader,
|
||||
const uint8_t *src,
|
||||
size_t srcLength
|
||||
);
|
||||
|
||||
/**
|
||||
* Null-terminates the line reader's current line buffer at lineLength.
|
||||
*
|
||||
* @param reader The line reader whose out buffer to terminate.
|
||||
*/
|
||||
void assetFileLineReaderTerminate(assetfilelinereader_t *reader);
|
||||
|
||||
/**
|
||||
* Searches the line reader's unread buffered bytes for a newline character.
|
||||
*
|
||||
* @param reader The line reader to search.
|
||||
* @return The index of the newline within readBuffer, or -1 if not found.
|
||||
*/
|
||||
ssize_t assetFileLineReaderFindNewline(const assetfilelinereader_t *reader);
|
||||
|
||||
/**
|
||||
* Refills the line reader's read buffer from the underlying file once its
|
||||
* buffered bytes are fully consumed. A no-op once the file is at EOF.
|
||||
*
|
||||
* @param reader The line reader to refill.
|
||||
* @return Error state if any.
|
||||
*/
|
||||
errorret_t assetFileLineReaderFill(assetfilelinereader_t *reader);
|
||||
|
||||
/**
|
||||
* Reads the next line from the asset file into the line buffer. The line
|
||||
* buffer is null-terminated and does not include the newline character.
|
||||
|
||||
@@ -17,3 +17,5 @@ add_subdirectory(locale)
|
||||
add_subdirectory(json)
|
||||
add_subdirectory(chunk)
|
||||
add_subdirectory(dmf)
|
||||
add_subdirectory(cutscene)
|
||||
add_subdirectory(wav)
|
||||
@@ -35,22 +35,6 @@ void assetEntryInit(
|
||||
entry->input = NULL;
|
||||
}
|
||||
refInit(&entry->refs, entry, NULL, NULL, NULL);
|
||||
|
||||
eventInit(
|
||||
&entry->onLoaded,
|
||||
entry->onLoadedCallbacks, entry->onLoadedUsers,
|
||||
ASSET_ENTRY_EVENT_MAX
|
||||
);
|
||||
eventInit(
|
||||
&entry->onUnloaded,
|
||||
entry->onUnloadedCallbacks, entry->onUnloadedUsers,
|
||||
ASSET_ENTRY_EVENT_MAX
|
||||
);
|
||||
eventInit(
|
||||
&entry->onError,
|
||||
entry->onErrorCallbacks, entry->onErrorUsers,
|
||||
ASSET_ENTRY_EVENT_MAX
|
||||
);
|
||||
}
|
||||
|
||||
void assetEntryLock(assetentry_t *entry) {
|
||||
@@ -97,7 +81,7 @@ errorret_t assetEntryDispose(assetentry_t *entry) {
|
||||
"Asset entry still refed at dispose time."
|
||||
);
|
||||
|
||||
eventInvoke(&entry->onUnloaded, entry);
|
||||
if(entry->onUnloaded) entry->onUnloaded(entry, entry->onUnloadedUser);
|
||||
errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry));
|
||||
memoryZero(entry, sizeof(assetentry_t));
|
||||
errorOk();
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
#pragma once
|
||||
#include "asset/loader/assetloading.h"
|
||||
#include "event/event.h"
|
||||
#include "util/ref.h"
|
||||
|
||||
typedef enum {
|
||||
@@ -20,11 +19,17 @@ typedef enum {
|
||||
ASSET_ENTRY_STATE_ERROR
|
||||
} assetentrystate_t;
|
||||
|
||||
/** Maximum number of subscribers for each per-entry event. */
|
||||
#define ASSET_ENTRY_EVENT_MAX 2
|
||||
|
||||
typedef struct assetentry_s assetentry_t;
|
||||
|
||||
/**
|
||||
* A single asset entry callback. Each entry supports at most one subscriber
|
||||
* per event - a second assignment without clearing the first is a bug.
|
||||
*
|
||||
* @param entry The assetentry_t the event fired on.
|
||||
* @param user The user pointer passed alongside the callback.
|
||||
*/
|
||||
typedef void (*assetentrycallback_t)(assetentry_t *entry, void *user);
|
||||
|
||||
struct assetentry_s {
|
||||
char_t name[ASSET_FILE_NAME_MAX];
|
||||
assetloadertype_t type;
|
||||
@@ -33,30 +38,27 @@ struct assetentry_s {
|
||||
ref_t refs;
|
||||
assetloaderinput_t *input;
|
||||
assetloaderinput_t inputData;
|
||||
/**
|
||||
* Fired once when loading completes successfully (params = assetentry_t *).
|
||||
* Always invoked on the main thread.
|
||||
*/
|
||||
event_t onLoaded;
|
||||
eventcallback_t onLoadedCallbacks[ASSET_ENTRY_EVENT_MAX];
|
||||
void *onLoadedUsers[ASSET_ENTRY_EVENT_MAX];
|
||||
|
||||
/**
|
||||
* Fired once when the entry is disposed/reaped (params = assetentry_t *).
|
||||
* The asset data is still accessible when the callback runs.
|
||||
* Fired once when loading completes successfully.
|
||||
* Always invoked on the main thread.
|
||||
*/
|
||||
event_t onUnloaded;
|
||||
eventcallback_t onUnloadedCallbacks[ASSET_ENTRY_EVENT_MAX];
|
||||
void *onUnloadedUsers[ASSET_ENTRY_EVENT_MAX];
|
||||
assetentrycallback_t onLoaded;
|
||||
void *onLoadedUser;
|
||||
|
||||
/**
|
||||
* Fired once when loading fails (params = assetentry_t *).
|
||||
* Fired once when the entry is disposed/reaped. The asset data is still
|
||||
* accessible when the callback runs. Always invoked on the main thread.
|
||||
*/
|
||||
assetentrycallback_t onUnloaded;
|
||||
void *onUnloadedUser;
|
||||
|
||||
/**
|
||||
* Fired once when loading fails.
|
||||
* Always invoked on the main thread.
|
||||
*/
|
||||
event_t onError;
|
||||
eventcallback_t onErrorCallbacks[ASSET_ENTRY_EVENT_MAX];
|
||||
void *onErrorUsers[ASSET_ENTRY_EVENT_MAX];
|
||||
assetentrycallback_t onError;
|
||||
void *onErrorUser;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,4 +51,16 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
|
||||
.loadAsync = assetChunkLoaderAsync,
|
||||
.dispose = assetChunkDispose
|
||||
},
|
||||
|
||||
[ASSET_LOADER_TYPE_CUTSCENE] = {
|
||||
.loadSync = assetCutsceneLoaderSync,
|
||||
.loadAsync = assetCutsceneLoaderAsync,
|
||||
.dispose = assetCutsceneDispose
|
||||
},
|
||||
|
||||
[ASSET_LOADER_TYPE_WAV] = {
|
||||
.loadSync = assetWavLoaderSync,
|
||||
.loadAsync = assetWavLoaderAsync,
|
||||
.dispose = assetWavDispose
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include "asset/loader/locale/assetlocaleloader.h"
|
||||
#include "asset/loader/json/assetjsonloader.h"
|
||||
#include "asset/loader/chunk/assetchunkloader.h"
|
||||
#include "asset/loader/cutscene/assetcutsceneloader.h"
|
||||
#include "asset/loader/wav/assetwavloader.h"
|
||||
|
||||
typedef enum {
|
||||
ASSET_LOADER_TYPE_NULL,
|
||||
@@ -24,6 +26,8 @@ typedef enum {
|
||||
ASSET_LOADER_TYPE_LOCALE,
|
||||
ASSET_LOADER_TYPE_JSON,
|
||||
ASSET_LOADER_TYPE_CHUNK,
|
||||
ASSET_LOADER_TYPE_CUTSCENE,
|
||||
ASSET_LOADER_TYPE_WAV,
|
||||
|
||||
ASSET_LOADER_TYPE_COUNT
|
||||
} assetloadertype_t;
|
||||
@@ -36,6 +40,8 @@ typedef union {
|
||||
assetlocaleloaderloading_t locale;
|
||||
assetjsonloaderloading_t json;
|
||||
assetchunkloaderloading_t chunk;
|
||||
assetcutsceneloaderloading_t cutscene;
|
||||
assetwavloaderloading_t wav;
|
||||
} assetloaderloading_t;
|
||||
|
||||
typedef union {
|
||||
@@ -46,6 +52,8 @@ typedef union {
|
||||
assetlocaleoutput_t locale;
|
||||
assetjsonoutput_t json;
|
||||
assetchunkoutput_t chunk;
|
||||
assetcutsceneoutput_t cutscene;
|
||||
assetwavoutput_t wav;
|
||||
} assetloaderoutput_t;
|
||||
|
||||
typedef union {
|
||||
@@ -54,6 +62,8 @@ typedef union {
|
||||
assetlocaleloaderinput_t locale;
|
||||
assetjsonloaderinput_t json;
|
||||
assetchunkloaderinput_t chunk;
|
||||
assetcutsceneloaderinput_t cutscene;
|
||||
assetwavloaderinput_t wav;
|
||||
} assetloaderinput_t;
|
||||
|
||||
typedef struct assetloading_s assetloading_t;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "asset/loader/assetentry.h"
|
||||
#include "asset/loader/assetloader.h"
|
||||
#include "asset/asset.h"
|
||||
#include "rpg/overworld/worldpos.h"
|
||||
|
||||
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
|
||||
assertNotNull(loading, "Loading cannot be NULL");
|
||||
@@ -111,9 +112,15 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
|
||||
size_t offset = 8;
|
||||
|
||||
size_t tileSize = CHUNK_TILE_COUNT * sizeof(tile_t);
|
||||
out->tiles = memoryAllocate(tileSize);
|
||||
memoryCopy(out->tiles, data + offset, tileSize);
|
||||
offset += tileSize;
|
||||
|
||||
for(size_t t = 0; t < CHUNK_TILE_COUNT; t++) {
|
||||
uint32_t *shape = (uint32_t *)&out->tiles[t].shape;
|
||||
*shape = endianLittleToHost32(*shape);
|
||||
}
|
||||
|
||||
out->meshCount = data[offset];
|
||||
offset += sizeof(uint8_t);
|
||||
assertTrue(
|
||||
@@ -135,6 +142,65 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
|
||||
|
||||
memoryCopy(out->meshOffsets[m], data + offset, sizeof(vec3));
|
||||
offset += sizeof(vec3);
|
||||
out->meshOffsets[m][0] = endianLittleToHostFloat(out->meshOffsets[m][0]);
|
||||
out->meshOffsets[m][1] = endianLittleToHostFloat(out->meshOffsets[m][1]);
|
||||
out->meshOffsets[m][2] = endianLittleToHostFloat(out->meshOffsets[m][2]);
|
||||
}
|
||||
|
||||
out->entitySpawnCount = data[offset];
|
||||
offset += sizeof(uint8_t);
|
||||
assertTrue(
|
||||
out->entitySpawnCount <= CHUNK_ENTITY_SPAWN_COUNT_MAX,
|
||||
"Chunk entity spawn count exceeds maximum."
|
||||
);
|
||||
|
||||
for(uint8_t s = 0; s < out->entitySpawnCount; s++) {
|
||||
chunkentityspawn_t *spawn = &out->entitySpawns[s];
|
||||
spawn->kind = (chunkentityspawnkind_t)data[offset];
|
||||
offset += sizeof(uint8_t);
|
||||
|
||||
uint16_t a;
|
||||
memoryCopy(&a, data + offset, sizeof(uint16_t));
|
||||
a = endianLittleToHost16(a);
|
||||
offset += sizeof(uint16_t);
|
||||
|
||||
uint8_t b = data[offset];
|
||||
offset += sizeof(uint8_t);
|
||||
|
||||
if(spawn->kind == CHUNK_ENTITY_SPAWN_KIND_ITEM) {
|
||||
spawn->globalId = 0;
|
||||
spawn->itemId = a;
|
||||
spawn->itemQuantity = b;
|
||||
} else {
|
||||
spawn->globalId = a;
|
||||
spawn->itemId = 0;
|
||||
spawn->itemQuantity = 0;
|
||||
}
|
||||
|
||||
spawn->position = worldPosReadLE(data, &offset);
|
||||
}
|
||||
|
||||
out->areaSpawnCount = data[offset];
|
||||
offset += sizeof(uint8_t);
|
||||
assertTrue(
|
||||
out->areaSpawnCount <= CHUNK_AREA_COUNT_MAX,
|
||||
"Chunk area spawn count exceeds maximum."
|
||||
);
|
||||
|
||||
for(uint8_t s = 0; s < out->areaSpawnCount; s++) {
|
||||
chunkareaspawn_t *area = &out->areaSpawns[s];
|
||||
area->min = worldPosReadLE(data, &offset);
|
||||
area->max = worldPosReadLE(data, &offset);
|
||||
|
||||
uint16_t callbackId;
|
||||
memoryCopy(&callbackId, data + offset, sizeof(uint16_t));
|
||||
area->callbackId = endianLittleToHost16(callbackId);
|
||||
offset += sizeof(uint16_t);
|
||||
|
||||
area->notify = data[offset];
|
||||
offset += sizeof(uint8_t);
|
||||
area->trigger = data[offset];
|
||||
offset += sizeof(uint8_t);
|
||||
}
|
||||
|
||||
memoryFree(data);
|
||||
@@ -157,6 +223,12 @@ errorret_t assetChunkDispose(assetentry_t *entry) {
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
assetchunkoutput_t *out = &entry->data.chunk;
|
||||
|
||||
if(out->tiles != NULL) {
|
||||
memoryFree(out->tiles);
|
||||
out->tiles = NULL;
|
||||
}
|
||||
|
||||
for(uint8_t m = 0; m < out->meshCount; m++) {
|
||||
if(out->modelEntries[m] == NULL) continue;
|
||||
assetUnlockEntry(out->modelEntries[m]);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include "asset/assetfile.h"
|
||||
#include "rpg/overworld/chunk.h"
|
||||
|
||||
#define ASSET_CHUNK_FILE_VERSION 4
|
||||
#define ASSET_CHUNK_FILE_VERSION 5
|
||||
|
||||
typedef struct assetloading_s assetloading_t;
|
||||
typedef struct assetentry_s assetentry_t;
|
||||
@@ -33,12 +33,39 @@ typedef struct {
|
||||
uint8_t modelIndex;
|
||||
} assetchunkloaderloading_t;
|
||||
|
||||
typedef enum {
|
||||
CHUNK_ENTITY_SPAWN_KIND_GLOBAL,
|
||||
CHUNK_ENTITY_SPAWN_KIND_ITEM
|
||||
} chunkentityspawnkind_t;
|
||||
|
||||
typedef struct {
|
||||
tile_t tiles[CHUNK_TILE_COUNT];
|
||||
chunkentityspawnkind_t kind;
|
||||
uint16_t globalId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL.
|
||||
uint16_t itemId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
|
||||
uint8_t itemQuantity; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
|
||||
worldpos_t position;
|
||||
} chunkentityspawn_t;
|
||||
|
||||
typedef struct {
|
||||
worldpos_t min;
|
||||
worldpos_t max;
|
||||
uint16_t callbackId; // Index into MAP_AREA_CALLBACK_LIST.
|
||||
uint8_t notify;
|
||||
uint8_t trigger;
|
||||
} chunkareaspawn_t;
|
||||
|
||||
typedef struct {
|
||||
tile_t *tiles;
|
||||
uint8_t meshCount;
|
||||
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
|
||||
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
|
||||
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
|
||||
|
||||
uint8_t entitySpawnCount;
|
||||
chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX];
|
||||
|
||||
uint8_t areaSpawnCount;
|
||||
chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX];
|
||||
} assetchunkoutput_t;
|
||||
|
||||
/**
|
||||
|
||||
+1
-5
@@ -5,9 +5,5 @@
|
||||
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
uisettings.c
|
||||
uisettingsgeneral.c
|
||||
uisettingsinput.c
|
||||
uisettingsdisplay.c
|
||||
uisettingsaudio.c
|
||||
assetcutsceneloader.c
|
||||
)
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "assetcutsceneloader.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
#include "util/endian.h"
|
||||
#include "asset/loader/assetloading.h"
|
||||
#include "asset/loader/assetentry.h"
|
||||
#include "asset/loader/assetloader.h"
|
||||
#include "asset/asset.h"
|
||||
#include "rpg/overworld/worldpos.h"
|
||||
|
||||
// DCTS header: magic "DCTS" (4), version u32 LE (4), pauseType u8 (1),
|
||||
// itemCount u8 (1), poolSize u16 LE (2) = 12 bytes.
|
||||
#define ASSET_CUTSCENE_HEADER_SIZE 12
|
||||
|
||||
uint8_t assetCutsceneReadU8(const uint8_t *data, size_t *offset) {
|
||||
uint8_t value = data[*offset];
|
||||
*offset += sizeof(uint8_t);
|
||||
return value;
|
||||
}
|
||||
|
||||
uint16_t assetCutsceneReadU16(const uint8_t *data, size_t *offset) {
|
||||
uint16_t value;
|
||||
memoryCopy(&value, data + *offset, sizeof(uint16_t));
|
||||
*offset += sizeof(uint16_t);
|
||||
return endianLittleToHost16(value);
|
||||
}
|
||||
|
||||
uint32_t assetCutsceneReadU32(const uint8_t *data, size_t *offset) {
|
||||
uint32_t value;
|
||||
memoryCopy(&value, data + *offset, sizeof(uint32_t));
|
||||
*offset += sizeof(uint32_t);
|
||||
return endianLittleToHost32(value);
|
||||
}
|
||||
|
||||
float_t assetCutsceneReadFloat(const uint8_t *data, size_t *offset) {
|
||||
float_t value;
|
||||
memoryCopy(&value, data + *offset, sizeof(float_t));
|
||||
*offset += sizeof(float_t);
|
||||
return endianLittleToHostFloat(value);
|
||||
}
|
||||
|
||||
// Copies a length-prefixed string directly into an item's own embedded
|
||||
// char_t[destCapacity] field (CUTSCENE_TEXT_MAX_CHARS and friends) - these
|
||||
// are never pool references, see the item-field inventory in the runtime
|
||||
// cutscene file design.
|
||||
void assetCutsceneReadEmbeddedString(
|
||||
const uint8_t *data,
|
||||
size_t *offset,
|
||||
char_t *dest,
|
||||
const size_t destCapacity
|
||||
) {
|
||||
uint8_t len = assetCutsceneReadU8(data, offset);
|
||||
assertTrue(len < destCapacity, "Cutscene string exceeds field capacity");
|
||||
memoryCopy(dest, data + *offset, len);
|
||||
dest[len] = '\0';
|
||||
*offset += len;
|
||||
}
|
||||
|
||||
// Resolves a u16 pool offset (read from the item stream) to a real pointer
|
||||
// into the entry's own persistent pool allocation.
|
||||
const char_t * assetCutsceneReadPoolString(
|
||||
const uint8_t *data,
|
||||
size_t *offset,
|
||||
const char_t *pool
|
||||
) {
|
||||
uint16_t poolOffset = assetCutsceneReadU16(data, offset);
|
||||
return pool + poolOffset;
|
||||
}
|
||||
|
||||
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) {
|
||||
assertNotNull(loading, "Loading cannot be NULL");
|
||||
assertNotMainThread("Should be called from an async thread.");
|
||||
|
||||
if(loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
assertNull(loading->loading.cutscene.data, "Data already defined?");
|
||||
|
||||
assetfile_t *file = &loading->loading.cutscene.file;
|
||||
assetLoaderErrorChain(loading,
|
||||
assetFileInit(file, loading->entry->name, NULL, NULL)
|
||||
);
|
||||
|
||||
uint8_t *data = memoryAllocate(file->size);
|
||||
assetLoaderErrorChain(loading, assetFileOpen(file));
|
||||
assetLoaderErrorChain(loading, assetFileRead(file, data, file->size));
|
||||
assertTrue(
|
||||
file->lastRead == file->size,
|
||||
"Failed to read entire cutscene file."
|
||||
);
|
||||
// Saved before assetFileDispose zeroes the whole assetfile_t struct
|
||||
// (including .size) - the sync phase needs the file's total length to
|
||||
// locate the pool region, which starts poolSize bytes before the end.
|
||||
loading->loading.cutscene.dataSize = (size_t)file->size;
|
||||
|
||||
assetLoaderErrorChain(loading, assetFileClose(file));
|
||||
assetLoaderErrorChain(loading, assetFileDispose(file));
|
||||
|
||||
loading->loading.cutscene.data = data;
|
||||
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE;
|
||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
|
||||
assertNotNull(loading, "Loading cannot be NULL");
|
||||
assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) {
|
||||
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE;
|
||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
assetcutsceneoutput_t *out = &loading->entry->data.cutscene;
|
||||
uint8_t *data = loading->loading.cutscene.data;
|
||||
assertNotNull(data, "Cutscene data should have been loaded by now.");
|
||||
|
||||
size_t fileSize = loading->loading.cutscene.dataSize;
|
||||
|
||||
if(data[0] != 'D' || data[1] != 'C' || data[2] != 'T' || data[3] != 'S') {
|
||||
memoryFree(data);
|
||||
assetLoaderErrorThrow(loading, "Invalid cutscene file header");
|
||||
}
|
||||
|
||||
size_t offset = 4;
|
||||
uint32_t version = assetCutsceneReadU32(data, &offset);
|
||||
if(version != ASSET_CUTSCENE_FILE_VERSION) {
|
||||
memoryFree(data);
|
||||
assetLoaderErrorThrow(
|
||||
loading, "Unsupported cutscene file version %u", version
|
||||
);
|
||||
}
|
||||
|
||||
cutscenepause_t pauseType = (cutscenepause_t)assetCutsceneReadU8(data, &offset);
|
||||
uint8_t itemCount = assetCutsceneReadU8(data, &offset);
|
||||
uint16_t poolSize = assetCutsceneReadU16(data, &offset);
|
||||
assertTrue(offset == ASSET_CUTSCENE_HEADER_SIZE, "Cutscene header size mismatch");
|
||||
|
||||
out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL;
|
||||
if(poolSize > 0) {
|
||||
size_t poolStart = fileSize - (size_t)poolSize;
|
||||
memoryCopy(out->pool, data + poolStart, poolSize);
|
||||
}
|
||||
const char_t *pool = out->pool;
|
||||
|
||||
out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t));
|
||||
memoryZero(out->items, itemCount * sizeof(cutsceneitem_t));
|
||||
|
||||
for(uint8_t i = 0; i < itemCount; i++) {
|
||||
cutsceneitem_t *item = &out->items[i];
|
||||
item->type = (cutsceneitemtype_t)assetCutsceneReadU8(data, &offset);
|
||||
|
||||
switch(item->type) {
|
||||
case CUTSCENE_ITEM_TYPE_TEXT:
|
||||
assetCutsceneReadEmbeddedString(
|
||||
data, &offset, item->text.text, CUTSCENE_TEXT_MAX_CHARS
|
||||
);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_TEXT_MINI:
|
||||
assetCutsceneReadEmbeddedString(
|
||||
data, &offset, item->textMini.text, CUTSCENE_TEXT_MINI_MAX_CHARS
|
||||
);
|
||||
item->textMini.position[0] = assetCutsceneReadFloat(data, &offset);
|
||||
item->textMini.position[1] = assetCutsceneReadFloat(data, &offset);
|
||||
item->textMini.position[2] = assetCutsceneReadFloat(data, &offset);
|
||||
item->textMini.duration = assetCutsceneReadFloat(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE:
|
||||
item->textMiniHide.index = assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_WAIT:
|
||||
item->wait = assetCutsceneReadFloat(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT:
|
||||
item->entityTeleport.entityIndex = assetCutsceneReadU8(data, &offset);
|
||||
item->entityTeleport.target = worldPosReadLE(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO: {
|
||||
item->entityWalkTo.entityIndex = assetCutsceneReadU8(data, &offset);
|
||||
item->entityWalkTo.walkAround = assetCutsceneReadU8(data, &offset) != 0;
|
||||
uint8_t count = assetCutsceneReadU8(data, &offset);
|
||||
uint16_t poolOffset = assetCutsceneReadU16(data, &offset);
|
||||
item->entityWalkTo.count = count;
|
||||
item->entityWalkTo.positions = (const worldpos_t *)(pool + poolOffset);
|
||||
break;
|
||||
}
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_FADE:
|
||||
item->fade.from.r = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.from.g = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.from.b = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.from.a = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.to.r = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.to.g = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.to.b = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.to.a = assetCutsceneReadU8(data, &offset);
|
||||
item->fade.duration = assetCutsceneReadFloat(data, &offset);
|
||||
item->fade.easing = (easingtype_t)assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_SET_PAUSE:
|
||||
item->setPause = (cutscenepause_t)assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_ITEM_GIVE:
|
||||
item->itemGive.item = (itemid_t)assetCutsceneReadU16(data, &offset);
|
||||
item->itemGive.quantity = assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_ENTITY_REMOVE:
|
||||
item->entityRemove.entityIndex = assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_ENTITY_ADD:
|
||||
item->entityAdd.entityType = assetCutsceneReadU8(data, &offset);
|
||||
item->entityAdd.position = worldPosReadLE(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_ENTITY_TURN:
|
||||
item->entityTurn.entityIndex = assetCutsceneReadU8(data, &offset);
|
||||
item->entityTurn.direction =
|
||||
(entitydir_t)assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY:
|
||||
item->entityWalkToEntity.entityIndex =
|
||||
assetCutsceneReadU8(data, &offset);
|
||||
item->entityWalkToEntity.targetEntityIndex =
|
||||
assetCutsceneReadU8(data, &offset);
|
||||
item->entityWalkToEntity.offsetX =
|
||||
worldUnitReadLE(data, &offset);
|
||||
item->entityWalkToEntity.offsetY =
|
||||
worldUnitReadLE(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_MAP_AREA_REMOVE:
|
||||
item->mapAreaRemove.areaId = assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_MAP_AREA_WAIT: {
|
||||
uint8_t count = assetCutsceneReadU8(data, &offset);
|
||||
uint16_t poolOffset = assetCutsceneReadU16(data, &offset);
|
||||
assertTrue(
|
||||
count <= CUTSCENE_MAP_AREA_WAIT_MAX,
|
||||
"Cutscene map area wait count exceeds maximum"
|
||||
);
|
||||
item->mapAreaWait.count = count;
|
||||
item->mapAreaWait.areaIds = (const uint8_t *)(pool + poolOffset);
|
||||
break;
|
||||
}
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_START_BATTLE: {
|
||||
item->startBattle.encounterType =
|
||||
(battleencountertype_t)assetCutsceneReadU8(data, &offset);
|
||||
item->startBattle.fleeAvailable =
|
||||
assetCutsceneReadU8(data, &offset) != 0;
|
||||
uint8_t enemyCount = assetCutsceneReadU8(data, &offset);
|
||||
assertTrue(
|
||||
enemyCount <= CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX,
|
||||
"Cutscene battle enemy count exceeds maximum"
|
||||
);
|
||||
item->startBattle.enemyCount = enemyCount;
|
||||
for(uint8_t e = 0; e < enemyCount; e++) {
|
||||
cutscenestartbattleenemy_t *enemy = &item->startBattle.enemies[e];
|
||||
enemy->stats.attack = assetCutsceneReadU16(data, &offset);
|
||||
enemy->stats.defense = assetCutsceneReadU16(data, &offset);
|
||||
enemy->stats.magic = assetCutsceneReadU16(data, &offset);
|
||||
enemy->stats.speed = assetCutsceneReadU16(data, &offset);
|
||||
enemy->stats.luck = assetCutsceneReadU16(data, &offset);
|
||||
enemy->healthMax = assetCutsceneReadU16(data, &offset);
|
||||
enemy->mpMax = assetCutsceneReadU16(data, &offset);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_EMOJI:
|
||||
item->emoji.entityIndex = assetCutsceneReadU8(data, &offset);
|
||||
item->emoji.duration = assetCutsceneReadFloat(data, &offset);
|
||||
item->emoji.emojiType = (uiemojitype_t)assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_SHAKE:
|
||||
item->shake.amount = assetCutsceneReadU8(data, &offset);
|
||||
item->shake.duration = assetCutsceneReadFloat(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE:
|
||||
item->battleWaitState.state =
|
||||
(battlestate_t)assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION:
|
||||
item->battleForceAction.fighterIndex = assetCutsceneReadU8(data, &offset);
|
||||
item->battleForceAction.targetIndex = assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_MODAL: {
|
||||
assetCutsceneReadEmbeddedString(
|
||||
data, &offset, item->modal.title, CUTSCENE_MODAL_TITLE_MAX_CHARS
|
||||
);
|
||||
assetCutsceneReadEmbeddedString(
|
||||
data, &offset, item->modal.message, CUTSCENE_MODAL_MESSAGE_MAX_CHARS
|
||||
);
|
||||
// v1 only supports the message-only form: MODAL and MODAL_OPTIONS
|
||||
// share this same tag with no separate discriminator, and there is
|
||||
// no native-callback registry yet to resolve an options callback.
|
||||
// Read into a local first (not inline in the check below) - on a
|
||||
// release build with DUSK_ASSERTIONS_FAKED, an assert's condition
|
||||
// is never evaluated at all, so a byte-consuming call inside one
|
||||
// would silently desync every item after it. This is untrusted
|
||||
// file content anyway, so it gets a real error, not an assert.
|
||||
uint8_t optionCount = assetCutsceneReadU8(data, &offset);
|
||||
if(optionCount != 0) {
|
||||
memoryFree(data);
|
||||
memoryFree(out->items);
|
||||
out->items = NULL;
|
||||
if(out->pool != NULL) {
|
||||
memoryFree(out->pool);
|
||||
out->pool = NULL;
|
||||
}
|
||||
assetLoaderErrorThrow(
|
||||
loading,
|
||||
"Cutscene MODAL item with options is not supported in "
|
||||
"file-based cutscenes yet - use MODAL_OPTIONS_MARKERS instead"
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS: {
|
||||
assetCutsceneReadEmbeddedString(
|
||||
data, &offset, item->modalOptionsMarkers.title,
|
||||
CUTSCENE_MODAL_TITLE_MAX_CHARS
|
||||
);
|
||||
assetCutsceneReadEmbeddedString(
|
||||
data, &offset, item->modalOptionsMarkers.message,
|
||||
CUTSCENE_MODAL_MESSAGE_MAX_CHARS
|
||||
);
|
||||
uint8_t optionCount = assetCutsceneReadU8(data, &offset);
|
||||
assertTrue(
|
||||
optionCount <= CUTSCENE_MODAL_OPTIONS_MARKERS_MAX,
|
||||
"Cutscene modal option count exceeds maximum"
|
||||
);
|
||||
item->modalOptionsMarkers.optionCount = optionCount;
|
||||
for(uint8_t o = 0; o < optionCount; o++) {
|
||||
item->modalOptionsMarkers.options[o] =
|
||||
assetCutsceneReadPoolString(data, &offset, pool);
|
||||
item->modalOptionsMarkers.markers[o] =
|
||||
assetCutsceneReadPoolString(data, &offset, pool);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_MODAL_CLOSE:
|
||||
case CUTSCENE_ITEM_TYPE_RESTART:
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_PRINT:
|
||||
assetCutsceneReadEmbeddedString(
|
||||
data, &offset, item->print.text, CUTSCENE_PRINT_MAX_CHARS
|
||||
);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_MARKER:
|
||||
item->marker.name = assetCutsceneReadPoolString(data, &offset, pool);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_SCENE:
|
||||
item->sceneChange.type = (scenetype_t)assetCutsceneReadU8(data, &offset);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK:
|
||||
item->saveDeviceCheck.successMarker =
|
||||
assetCutsceneReadPoolString(data, &offset, pool);
|
||||
item->saveDeviceCheck.failureMarker =
|
||||
assetCutsceneReadPoolString(data, &offset, pool);
|
||||
break;
|
||||
|
||||
case CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS:
|
||||
item->saveLoadAllSlots.successMarker =
|
||||
assetCutsceneReadPoolString(data, &offset, pool);
|
||||
item->saveLoadAllSlots.failureMarker =
|
||||
assetCutsceneReadPoolString(data, &offset, pool);
|
||||
break;
|
||||
|
||||
default:
|
||||
memoryFree(data);
|
||||
memoryFree(out->items);
|
||||
out->items = NULL;
|
||||
if(out->pool != NULL) {
|
||||
memoryFree(out->pool);
|
||||
out->pool = NULL;
|
||||
}
|
||||
assetLoaderErrorThrow(
|
||||
loading,
|
||||
"Cutscene item type %u is not supported in file-based cutscenes "
|
||||
"(item %u/%u, offset %u/%u, poolSize %u)",
|
||||
(uint32_t)item->type, (uint32_t)i, (uint32_t)itemCount,
|
||||
(uint32_t)offset, (uint32_t)fileSize, (uint32_t)poolSize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
memoryFree(data);
|
||||
loading->loading.cutscene.data = NULL;
|
||||
|
||||
out->cutscene.items = out->items;
|
||||
out->cutscene.itemCount = itemCount;
|
||||
out->cutscene.pause = pauseType;
|
||||
out->cutscene.dataSize = 0;
|
||||
|
||||
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetCutsceneDispose(assetentry_t *entry) {
|
||||
assertNotNull(entry, "Entry cannot be NULL");
|
||||
assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
assetcutsceneoutput_t *out = &entry->data.cutscene;
|
||||
|
||||
if(out->items != NULL) {
|
||||
memoryFree(out->items);
|
||||
out->items = NULL;
|
||||
}
|
||||
|
||||
if(out->pool != NULL) {
|
||||
memoryFree(out->pool);
|
||||
out->pool = NULL;
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "asset/assetfile.h"
|
||||
#include "rpg/cutscene/cutscene.h"
|
||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
||||
|
||||
#define ASSET_CUTSCENE_FILE_VERSION 1
|
||||
|
||||
typedef struct assetloading_s assetloading_t;
|
||||
typedef struct assetentry_s assetentry_t;
|
||||
|
||||
typedef struct {
|
||||
void *nothing;
|
||||
} assetcutsceneloaderinput_t;
|
||||
|
||||
typedef enum {
|
||||
ASSET_CUTSCENE_LOADING_STATE_INITIAL,
|
||||
ASSET_CUTSCENE_LOADING_STATE_READ_FILE,
|
||||
ASSET_CUTSCENE_LOADING_STATE_PARSE
|
||||
} assetcutsceneloadingstate_t;
|
||||
|
||||
typedef struct {
|
||||
assetfile_t file;
|
||||
assetcutsceneloadingstate_t state;
|
||||
uint8_t *data;
|
||||
size_t dataSize;// Saved before assetFileDispose zeroes file.size.
|
||||
} assetcutsceneloaderloading_t;
|
||||
|
||||
// Runtime-loaded cutscene: items/pool are heap-allocated to the file's
|
||||
// actual declared sizes (not fixed-capacity), so an entry that never holds
|
||||
// a cutscene costs nothing extra in the shared assetloaderoutput_t union -
|
||||
// see assetchunkoutput_t.tiles for the same pattern.
|
||||
typedef struct {
|
||||
cutscene_t cutscene; // .items points at the items array below
|
||||
cutsceneitem_t *items;
|
||||
char_t *pool;
|
||||
} assetcutsceneoutput_t;
|
||||
|
||||
/**
|
||||
* Reads a uint8_t from the current offset, advancing *offset past it.
|
||||
*
|
||||
* @param data The buffer to read from.
|
||||
* @param offset In/out cursor into data, advanced past the value read.
|
||||
* @return The decoded uint8_t.
|
||||
*/
|
||||
uint8_t assetCutsceneReadU8(const uint8_t *data, size_t *offset);
|
||||
|
||||
/**
|
||||
* Reads a little-endian uint16_t from a potentially-unaligned offset,
|
||||
* advancing *offset past it.
|
||||
*
|
||||
* @param data The buffer to read from.
|
||||
* @param offset In/out cursor into data, advanced past the value read.
|
||||
* @return The decoded uint16_t.
|
||||
*/
|
||||
uint16_t assetCutsceneReadU16(const uint8_t *data, size_t *offset);
|
||||
|
||||
/**
|
||||
* Reads a little-endian uint32_t from a potentially-unaligned offset,
|
||||
* advancing *offset past it.
|
||||
*
|
||||
* @param data The buffer to read from.
|
||||
* @param offset In/out cursor into data, advanced past the value read.
|
||||
* @return The decoded uint32_t.
|
||||
*/
|
||||
uint32_t assetCutsceneReadU32(const uint8_t *data, size_t *offset);
|
||||
|
||||
/**
|
||||
* Reads a little-endian float_t from a potentially-unaligned offset,
|
||||
* advancing *offset past it.
|
||||
*
|
||||
* @param data The buffer to read from.
|
||||
* @param offset In/out cursor into data, advanced past the value read.
|
||||
* @return The decoded float_t.
|
||||
*/
|
||||
float_t assetCutsceneReadFloat(const uint8_t *data, size_t *offset);
|
||||
|
||||
/**
|
||||
* Copies a length-prefixed string directly into an item's own embedded
|
||||
* char_t[destCapacity] field (CUTSCENE_TEXT_MAX_CHARS and friends) - these
|
||||
* are never pool references, see the item-field inventory in the runtime
|
||||
* cutscene file design.
|
||||
*
|
||||
* @param data The buffer to read from.
|
||||
* @param offset In/out cursor into data, advanced past the string read.
|
||||
* @param dest Destination buffer to copy the string into.
|
||||
* @param destCapacity Capacity of dest, including the null terminator.
|
||||
*/
|
||||
void assetCutsceneReadEmbeddedString(
|
||||
const uint8_t *data,
|
||||
size_t *offset,
|
||||
char_t *dest,
|
||||
const size_t destCapacity
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolves a u16 pool offset (read from the item stream) to a real pointer
|
||||
* into the entry's own persistent pool allocation.
|
||||
*
|
||||
* @param data The buffer to read the pool offset from.
|
||||
* @param offset In/out cursor into data, advanced past the pool offset.
|
||||
* @param pool The base pointer of the entry's persistent pool allocation.
|
||||
* @return Pointer to the string within pool.
|
||||
*/
|
||||
const char_t * assetCutsceneReadPoolString(
|
||||
const uint8_t *data,
|
||||
size_t *offset,
|
||||
const char_t *pool
|
||||
);
|
||||
|
||||
/**
|
||||
* Asynchronous loader for cutscene assets. Reads the raw DCTS file bytes
|
||||
* into the loading buffer so the sync phase can parse without blocking the
|
||||
* main thread on I/O.
|
||||
*
|
||||
* @param loading Loading information for the asset being loaded.
|
||||
* @return Error code indicating success or failure of the load operation.
|
||||
*/
|
||||
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
|
||||
|
||||
/**
|
||||
* Synchronous loader for cutscene assets. Validates the DCTS binary
|
||||
* previously read by the async phase and decodes it into a heap-allocated
|
||||
* cutsceneitem_t array + string/data pool.
|
||||
*
|
||||
* @param loading Loading information for the asset being loaded.
|
||||
* @return Error code indicating success or failure of the load operation.
|
||||
*/
|
||||
errorret_t assetCutsceneLoaderSync(assetloading_t *loading);
|
||||
|
||||
/**
|
||||
* Disposer for cutscene assets.
|
||||
*
|
||||
* @param entry Asset entry containing the cutscene data to dispose.
|
||||
* @return Error code indicating success or failure of the dispose operation.
|
||||
*/
|
||||
errorret_t assetCutsceneDispose(assetentry_t *entry);
|
||||
@@ -48,8 +48,20 @@ errorret_t assetMeshLoaderAsync(assetloading_t *loading) {
|
||||
uint32_t vertCount = endianLittleToHost32(*(uint32_t *)(raw + 8));
|
||||
meshvertex_t *vertices = NULL;
|
||||
if(vertCount > 0) {
|
||||
vertices = memoryAllocate(vertCount * sizeof(meshvertex_t));
|
||||
// 32-byte (cache-line) aligned: GX_SetArray + DCFlushRange on Dolphin
|
||||
// require this for the DMA'd vertex data to actually reach the GPU
|
||||
// coherently. Static compiled-in vertex arrays happen to get this from
|
||||
// the linker; a plain memoryAllocate here would not.
|
||||
vertices = memoryAlign(32, vertCount * sizeof(meshvertex_t));
|
||||
memoryCopy(vertices, raw + 12, vertCount * sizeof(meshvertex_t));
|
||||
|
||||
for(uint32_t v = 0; v < vertCount; v++) {
|
||||
vertices[v].uv[0] = endianLittleToHostFloat(vertices[v].uv[0]);
|
||||
vertices[v].uv[1] = endianLittleToHostFloat(vertices[v].uv[1]);
|
||||
vertices[v].pos[0] = endianLittleToHostFloat(vertices[v].pos[0]);
|
||||
vertices[v].pos[1] = endianLittleToHostFloat(vertices[v].pos[1]);
|
||||
vertices[v].pos[2] = endianLittleToHostFloat(vertices[v].pos[2]);
|
||||
}
|
||||
}
|
||||
memoryFree(raw);
|
||||
|
||||
@@ -103,18 +115,22 @@ errorret_t assetMeshLoaderSync(assetloading_t *loading) {
|
||||
out->vertices = NULL;
|
||||
errorChain(ret);
|
||||
}
|
||||
out->meshInitialized = true;
|
||||
|
||||
ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
|
||||
if(errorIsNotOk(ret)) {
|
||||
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
|
||||
meshDispose(&out->mesh);
|
||||
out->meshInitialized = false;
|
||||
memoryFree(out->vertices);
|
||||
out->vertices = NULL;
|
||||
errorChain(ret);
|
||||
}
|
||||
|
||||
#ifndef DUSK_OPENGL_LEGACY
|
||||
// VBO owns the data now; CPU copy is no longer needed.
|
||||
#if defined(DUSK_OPENGL) && !defined(DUSK_OPENGL_LEGACY)
|
||||
// VBO owns the data now; CPU copy is no longer needed. The platform
|
||||
// mesh object itself still needs meshDispose later - tracked via
|
||||
// out->meshInitialized, independent of the CPU buffer's lifetime.
|
||||
memoryFree(out->vertices);
|
||||
out->vertices = NULL;
|
||||
#endif
|
||||
@@ -129,8 +145,11 @@ errorret_t assetMeshDispose(assetentry_t *entry) {
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
assetmeshoutput_t *out = &entry->data.mesh;
|
||||
if(out->vertices != NULL) {
|
||||
if(out->meshInitialized) {
|
||||
errorChain(meshDispose(&out->mesh));
|
||||
out->meshInitialized = false;
|
||||
}
|
||||
if(out->vertices != NULL) {
|
||||
memoryFree(out->vertices);
|
||||
out->vertices = NULL;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ typedef struct {
|
||||
|
||||
typedef struct {
|
||||
mesh_t mesh;
|
||||
bool_t meshInitialized;
|
||||
meshvertex_t *vertices;
|
||||
} assetmeshoutput_t;
|
||||
|
||||
|
||||
@@ -71,6 +71,12 @@ errorret_t assetLocaleDispose(assetentry_t *entry) {
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
assetlocalefile_t *localeFile = &entry->data.locale;
|
||||
|
||||
if(localeFile->cache != NULL) {
|
||||
memoryFree(localeFile->cache);
|
||||
localeFile->cache = NULL;
|
||||
}
|
||||
|
||||
errorChain(assetFileClose(&localeFile->file));
|
||||
return assetFileDispose(&localeFile->file);
|
||||
}
|
||||
@@ -470,6 +476,83 @@ errorret_t assetLocaleLineUnbuffer(
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetLocaleCacheFind(
|
||||
assetlocalefile_t *file,
|
||||
const char_t *messageId,
|
||||
const int32_t pluralCount,
|
||||
char_t *stringBuffer,
|
||||
const size_t stringBufferSize,
|
||||
bool_t *outHit
|
||||
) {
|
||||
assertNotNull(file, "Asset file cannot be NULL.");
|
||||
assertNotNull(messageId, "Message ID cannot be NULL.");
|
||||
assertNotNull(outHit, "outHit cannot be NULL.");
|
||||
|
||||
*outHit = false;
|
||||
if(file->cache == NULL) errorOk();
|
||||
|
||||
assetlocalecache_t *cache = file->cache;
|
||||
for(uint8_t i = 0; i < ASSET_LOCALE_CACHE_COUNT; i++) {
|
||||
assetlocalecacheentry_t *entry = &cache->entries[i];
|
||||
if(entry->messageId[0] == '\0') break;// unused tail - nothing further
|
||||
if(entry->pluralCount != pluralCount) continue;
|
||||
if(!stringEquals(entry->messageId, messageId)) continue;
|
||||
|
||||
// Move to the front of the LRU order (a no-op when already there).
|
||||
if(i > 0) {
|
||||
assetlocalecacheentry_t hit = *entry;
|
||||
memoryMove(
|
||||
&cache->entries[1], &cache->entries[0], i * sizeof(assetlocalecacheentry_t)
|
||||
);
|
||||
cache->entries[0] = hit;
|
||||
}
|
||||
|
||||
size_t len = strlen(cache->entries[0].value);
|
||||
if(len >= stringBufferSize) errorThrow("String buffer overflow");
|
||||
memoryCopy(stringBuffer, cache->entries[0].value, len + 1);
|
||||
*outHit = true;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
void assetLocaleCacheInsert(
|
||||
assetlocalefile_t *file,
|
||||
const char_t *messageId,
|
||||
const int32_t pluralCount,
|
||||
const char_t *value
|
||||
) {
|
||||
assertNotNull(file, "Asset file cannot be NULL.");
|
||||
assertNotNull(messageId, "Message ID cannot be NULL.");
|
||||
assertNotNull(value, "Value cannot be NULL.");
|
||||
|
||||
// Caching is a pure optimization - a message ID or value too long for a
|
||||
// fixed cache slot is just never cached, not an error.
|
||||
if(strlen(messageId) >= ASSET_LOCALE_CACHE_KEY_MAX) return;
|
||||
if(strlen(value) >= ASSET_LOCALE_CACHE_VALUE_MAX) return;
|
||||
|
||||
if(file->cache == NULL) {
|
||||
file->cache = (assetlocalecache_t *)memoryAllocate(sizeof(assetlocalecache_t));
|
||||
memoryZero(file->cache, sizeof(assetlocalecache_t));
|
||||
}
|
||||
|
||||
// Shift every entry down one slot, dropping the least-recently-used one
|
||||
// off the end, to make room for the new entry at the front. This assumes
|
||||
// messageId is never already present elsewhere in the cache, which holds
|
||||
// as long as callers only insert after a confirmed assetLocaleCacheFind
|
||||
// miss (a hit would have returned before reaching this point).
|
||||
memoryMove(
|
||||
&file->cache->entries[1], &file->cache->entries[0],
|
||||
(ASSET_LOCALE_CACHE_COUNT - 1) * sizeof(assetlocalecacheentry_t)
|
||||
);
|
||||
|
||||
assetlocalecacheentry_t *entry = &file->cache->entries[0];
|
||||
stringCopy(entry->messageId, messageId, ASSET_LOCALE_CACHE_KEY_MAX);
|
||||
entry->pluralCount = pluralCount;
|
||||
stringCopy(entry->value, value, ASSET_LOCALE_CACHE_VALUE_MAX);
|
||||
}
|
||||
|
||||
errorret_t assetLocaleGetString(
|
||||
assetlocalefile_t *file,
|
||||
const char_t *messageId,
|
||||
@@ -482,6 +565,13 @@ errorret_t assetLocaleGetString(
|
||||
assertTrue(pluralCount >= 0, "Plural index cannot be negative.");
|
||||
assertNotNull(stringBuffer, "String buffer cannot be NULL.");
|
||||
assertTrue(stringBufferSize > 0, "String buffer size must be > 0");
|
||||
|
||||
bool_t cacheHit = false;
|
||||
errorChain(assetLocaleCacheFind(
|
||||
file, messageId, pluralCount, stringBuffer, stringBufferSize, &cacheHit
|
||||
));
|
||||
if(cacheHit) errorOk();
|
||||
|
||||
assetfilelinereader_t reader;
|
||||
|
||||
bool_t msgidFound = false, msgidPluralFound = false, msgstrFound = false;
|
||||
@@ -625,6 +715,8 @@ errorret_t assetLocaleGetString(
|
||||
errorThrow("Failed to find msgstr for message ID: %s", messageId);
|
||||
}
|
||||
|
||||
assetLocaleCacheInsert(file, messageId, pluralCount, stringBuffer);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,41 @@ typedef struct {
|
||||
};
|
||||
} assetlocalearg_t;
|
||||
|
||||
/** Number of recently resolved strings @ref assetlocalecache_t remembers. */
|
||||
#define ASSET_LOCALE_CACHE_COUNT 128
|
||||
|
||||
/** Max length (excluding null terminator) of a cacheable message ID. */
|
||||
#define ASSET_LOCALE_CACHE_KEY_MAX 64
|
||||
|
||||
/** Max length (excluding null terminator) of a cacheable resolved string. */
|
||||
#define ASSET_LOCALE_CACHE_VALUE_MAX 256
|
||||
|
||||
/** One (messageId, pluralCount) -> resolved string cache slot. */
|
||||
typedef struct {
|
||||
char_t messageId[ASSET_LOCALE_CACHE_KEY_MAX];
|
||||
int32_t pluralCount;
|
||||
char_t value[ASSET_LOCALE_CACHE_VALUE_MAX];
|
||||
} assetlocalecacheentry_t;
|
||||
|
||||
/**
|
||||
* Fixed-size move-to-front LRU cache of recently resolved locale strings.
|
||||
* assetLocaleGetString() re-scans and re-decompresses the whole PO file on
|
||||
* every miss, which is expensive to repeat for strings a screen fetches
|
||||
* every time it opens (menu labels, etc) - this cache lets identical
|
||||
* (messageId, pluralCount) lookups skip that entirely.
|
||||
*
|
||||
* Lazily allocated on the first cache insert (see assetlocalefile_t.cache)
|
||||
* so locale entries that are never queried, or a file that's disposed
|
||||
* before anything is cached, never pay for it.
|
||||
*/
|
||||
typedef struct {
|
||||
/**
|
||||
* Entries ordered most-recently-used first. An entry with
|
||||
* messageId[0] == '\0' (and every entry after it) is unused.
|
||||
*/
|
||||
assetlocalecacheentry_t entries[ASSET_LOCALE_CACHE_COUNT];
|
||||
} assetlocalecache_t;
|
||||
|
||||
/**
|
||||
* Runtime state for an open locale file.
|
||||
*
|
||||
@@ -98,6 +133,16 @@ typedef struct {
|
||||
|
||||
/** Form index used when no conditional clause matches. */
|
||||
uint8_t pluralDefaultIndex;
|
||||
|
||||
/**
|
||||
* Recently resolved string cache, or NULL if nothing has been cached yet.
|
||||
* Heap-allocated rather than embedded because assetlocalefile_t lives
|
||||
* inside the shared assetloaderoutput_t union alongside every other
|
||||
* asset type - embedding a 128-entry cache there would size that union
|
||||
* (and therefore every one of the ASSET_ENTRY_COUNT_MAX asset slots,
|
||||
* regardless of what type of asset occupies it) up by the same amount.
|
||||
*/
|
||||
assetlocalecache_t *cache;
|
||||
} assetlocalefile_t;
|
||||
|
||||
/** Convenience alias - the loaded output type of a locale asset entry. */
|
||||
@@ -203,12 +248,64 @@ errorret_t assetLocaleLineUnbuffer(
|
||||
const size_t stringBufferSize
|
||||
);
|
||||
|
||||
/**
|
||||
* Searches a locale file's cache for a previously resolved
|
||||
* (messageId, pluralCount) pair.
|
||||
*
|
||||
* On a hit, copies the cached value into `stringBuffer` (erroring if it
|
||||
* doesn't fit, same contract as @ref assetLocaleGetString) and moves the
|
||||
* entry to the front of the cache's move-to-front LRU order. On a miss,
|
||||
* `stringBuffer` is left untouched.
|
||||
*
|
||||
* @param file Locale file whose cache to search. Its cache may be NULL
|
||||
* (nothing cached yet), which is treated as a miss.
|
||||
* @param messageId Message ID to look up.
|
||||
* @param pluralCount Plural count the original lookup used.
|
||||
* @param stringBuffer Destination buffer, filled only on a hit.
|
||||
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
|
||||
* @param outHit Set to true on a cache hit, false on a miss.
|
||||
* @return OK on success (hit or miss), error if a hit's cached value does
|
||||
* not fit `stringBuffer`.
|
||||
*/
|
||||
errorret_t assetLocaleCacheFind(
|
||||
assetlocalefile_t *file,
|
||||
const char_t *messageId,
|
||||
const int32_t pluralCount,
|
||||
char_t *stringBuffer,
|
||||
const size_t stringBufferSize,
|
||||
bool_t *outHit
|
||||
);
|
||||
|
||||
/**
|
||||
* Records a resolved (messageId, pluralCount) -> value string at the front
|
||||
* of the cache's move-to-front LRU order, evicting the least-recently-used
|
||||
* entry if the cache is already full. Lazily allocates the cache (see
|
||||
* assetlocalefile_t.cache) on the first call.
|
||||
*
|
||||
* Caching is a pure optimization: if `messageId` or `value` is too long to
|
||||
* fit the cache's fixed-size slots, this silently does nothing rather than
|
||||
* erroring or growing the cache.
|
||||
*
|
||||
* @param file Locale file whose cache to insert into.
|
||||
* @param messageId Message ID that was looked up.
|
||||
* @param pluralCount Plural count the lookup was made with.
|
||||
* @param value Resolved string to cache.
|
||||
*/
|
||||
void assetLocaleCacheInsert(
|
||||
assetlocalefile_t *file,
|
||||
const char_t *messageId,
|
||||
const int32_t pluralCount,
|
||||
const char_t *value
|
||||
);
|
||||
|
||||
/**
|
||||
* Looks up a translated string by message ID from the open locale file.
|
||||
*
|
||||
* Rewinds the file and scans from the beginning on every call. For plural
|
||||
* entries (`msgid_plural`) the `pluralCount` is evaluated against the loaded
|
||||
* plural rules to select the correct `msgstr[N]` form.
|
||||
* Checks the file's cache first (see @ref assetLocaleCacheFind); on a miss,
|
||||
* rewinds the file and scans from the beginning, then caches the result
|
||||
* (see @ref assetLocaleCacheInsert) before returning. For plural entries
|
||||
* (`msgid_plural`) the `pluralCount` is evaluated against the loaded plural
|
||||
* rules to select the correct `msgstr[N]` form.
|
||||
*
|
||||
* @param file Locale file to search. Must be open.
|
||||
* @param messageId PO message ID to find (`""` retrieves the header entry).
|
||||
|
||||
@@ -6,6 +6,5 @@
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
savevita.c
|
||||
savestreamvita.c
|
||||
assetwavloader.c
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "assetwavloader.h"
|
||||
#include "util/memory.h"
|
||||
#include "util/endian.h"
|
||||
#include "assert/assert.h"
|
||||
#include "asset/loader/assetloading.h"
|
||||
#include "asset/loader/assetentry.h"
|
||||
|
||||
// "RIFF" + chunkSize + "WAVE", before any sub-chunks begin.
|
||||
#define ASSET_WAV_RIFF_HEADER_SIZE 12
|
||||
|
||||
// A sub-chunk header is a 4-byte id followed by a 4-byte little-endian size.
|
||||
#define ASSET_WAV_CHUNK_HEADER_SIZE 8
|
||||
|
||||
#define ASSET_WAV_FMT_CHUNK_SIZE_MIN 16
|
||||
// Sane upper bound for a stack buffer - real fmt chunks (PCM or otherwise)
|
||||
// never come close to this; anything bigger is treated as malformed.
|
||||
#define ASSET_WAV_FMT_CHUNK_SIZE_MAX 64
|
||||
|
||||
errorret_t assetWavLoaderAsync(assetloading_t *loading) {
|
||||
assertNotNull(loading, "Loading cannot be NULL");
|
||||
assertNotMainThread("Async loader should not be on main thread.");
|
||||
|
||||
if(loading->loading.wav.state != ASSET_WAV_LOADER_STATE_READ_HEADER) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
assetwavfile_t *wavFile = &loading->entry->data.wav;
|
||||
memoryZero(wavFile, sizeof(assetwavfile_t));
|
||||
|
||||
assetfile_t file;
|
||||
assetLoaderErrorChain(loading, assetFileInit(
|
||||
&file, loading->entry->name, NULL, NULL
|
||||
));
|
||||
assetLoaderErrorChain(loading, assetFileOpen(&file));
|
||||
assetLoaderErrorChain(loading, assetWavParseHeader(&file, wavFile));
|
||||
assetLoaderErrorChain(loading, assetFileClose(&file));
|
||||
assetLoaderErrorChain(loading, assetFileDispose(&file));
|
||||
|
||||
loading->loading.wav.state = ASSET_WAV_LOADER_STATE_DONE;
|
||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetWavLoaderSync(assetloading_t *loading) {
|
||||
assertNotNull(loading, "Loading cannot be NULL");
|
||||
assertTrue(loading->type == ASSET_LOADER_TYPE_WAV, "Invalid type.");
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
switch(loading->loading.wav.state) {
|
||||
case ASSET_WAV_LOADER_STATE_INITIAL:
|
||||
loading->loading.wav.state = ASSET_WAV_LOADER_STATE_READ_HEADER;
|
||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
|
||||
errorOk();
|
||||
break;
|
||||
|
||||
case ASSET_WAV_LOADER_STATE_DONE:
|
||||
break;
|
||||
|
||||
default:
|
||||
errorOk();
|
||||
}
|
||||
|
||||
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetWavDispose(assetentry_t *entry) {
|
||||
assertNotNull(entry, "Asset entry cannot be NULL");
|
||||
assertTrue(entry->type == ASSET_LOADER_TYPE_WAV, "Invalid type.");
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t assetWavParseHeader(
|
||||
assetfile_t *file,
|
||||
assetwavfile_t *wavFile
|
||||
) {
|
||||
assertNotNull(file, "Asset file cannot be NULL.");
|
||||
assertNotNull(wavFile, "Wav file cannot be NULL.");
|
||||
|
||||
uint8_t riffHeader[ASSET_WAV_RIFF_HEADER_SIZE];
|
||||
errorChain(assetFileRead(file, riffHeader, sizeof(riffHeader)));
|
||||
|
||||
if(memoryCompare(riffHeader, "RIFF", 4) != 0) {
|
||||
errorThrow("WAV file has an invalid RIFF header: %s", file->filename);
|
||||
}
|
||||
if(memoryCompare(riffHeader + 8, "WAVE", 4) != 0) {
|
||||
errorThrow("WAV file has an invalid WAVE header: %s", file->filename);
|
||||
}
|
||||
|
||||
bool_t foundFormat = false;
|
||||
bool_t foundData = false;
|
||||
uint16_t audioFormat = 0;
|
||||
|
||||
// Walk the chunk list, reading only chunk headers (and the small "fmt "
|
||||
// body) - every other chunk, "data"'s sample bytes included, is skipped
|
||||
// via a NULL-buffer read rather than buffered into memory. Stops as soon
|
||||
// as "data" is found; nothing after it matters here.
|
||||
while(!foundData && (size_t) file->position < (size_t) file->size) {
|
||||
uint8_t chunkHeader[ASSET_WAV_CHUNK_HEADER_SIZE];
|
||||
errorChain(assetFileRead(file, chunkHeader, sizeof(chunkHeader)));
|
||||
|
||||
uint32_t chunkSizeLE;
|
||||
memoryCopy(&chunkSizeLE, chunkHeader + 4, sizeof(chunkSizeLE));
|
||||
const uint32_t chunkSize = endianLittleToHost32(chunkSizeLE);
|
||||
|
||||
// Chunks are padded to an even total size - the pad byte (if any)
|
||||
// isn't included in chunkSize but still needs to be skipped over.
|
||||
const uint32_t chunkSizePadded = chunkSize + (chunkSize % 2);
|
||||
|
||||
if(memoryCompare(chunkHeader, "fmt ", 4) == 0) {
|
||||
if(
|
||||
chunkSize < ASSET_WAV_FMT_CHUNK_SIZE_MIN ||
|
||||
chunkSize > ASSET_WAV_FMT_CHUNK_SIZE_MAX
|
||||
) {
|
||||
errorThrow("WAV 'fmt ' chunk has an unsupported size: %u", chunkSize);
|
||||
}
|
||||
|
||||
uint8_t fmtBuffer[ASSET_WAV_FMT_CHUNK_SIZE_MAX];
|
||||
errorChain(assetFileRead(file, fmtBuffer, chunkSize));
|
||||
if(chunkSize % 2 != 0) {
|
||||
errorChain(assetFileRead(file, NULL, 1));
|
||||
}
|
||||
|
||||
uint16_t u16;
|
||||
uint32_t u32;
|
||||
|
||||
memoryCopy(&u16, fmtBuffer + 0, sizeof(u16));
|
||||
audioFormat = endianLittleToHost16(u16);
|
||||
|
||||
memoryCopy(&u16, fmtBuffer + 2, sizeof(u16));
|
||||
wavFile->channels = (uint8_t) endianLittleToHost16(u16);
|
||||
|
||||
memoryCopy(&u32, fmtBuffer + 4, sizeof(u32));
|
||||
wavFile->sampleRate = endianLittleToHost32(u32);
|
||||
|
||||
memoryCopy(&u16, fmtBuffer + 14, sizeof(u16));
|
||||
wavFile->bitsPerSample = (uint8_t) endianLittleToHost16(u16);
|
||||
|
||||
foundFormat = true;
|
||||
} else if(memoryCompare(chunkHeader, "data", 4) == 0) {
|
||||
wavFile->dataOffset = (size_t) file->position;
|
||||
wavFile->dataSize = chunkSize;
|
||||
foundData = true;
|
||||
} else {
|
||||
errorChain(assetFileRead(file, NULL, chunkSizePadded));
|
||||
}
|
||||
}
|
||||
|
||||
if(!foundFormat) {
|
||||
errorThrow("WAV file is missing its 'fmt ' chunk: %s", file->filename);
|
||||
}
|
||||
if(!foundData) {
|
||||
errorThrow("WAV file is missing its 'data' chunk: %s", file->filename);
|
||||
}
|
||||
if(audioFormat != 1) {
|
||||
errorThrow(
|
||||
"Unsupported WAV audio format: %u (only PCM is supported): %s",
|
||||
audioFormat, file->filename
|
||||
);
|
||||
}
|
||||
if(wavFile->bitsPerSample != 16 && wavFile->bitsPerSample != 24) {
|
||||
errorThrow(
|
||||
"Unsupported WAV bits per sample: %u (only 16/24-bit is supported): %s",
|
||||
wavFile->bitsPerSample, file->filename
|
||||
);
|
||||
}
|
||||
if(wavFile->channels == 0) {
|
||||
errorThrow("WAV file declares 0 channels: %s", file->filename);
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "asset/assetfile.h"
|
||||
|
||||
typedef struct assetloading_s assetloading_t;
|
||||
typedef struct assetentry_s assetentry_t;
|
||||
|
||||
/** Input passed to the wav loader - currently unused. */
|
||||
typedef struct { void *nothing; } assetwavloaderinput_t;
|
||||
|
||||
typedef enum {
|
||||
ASSET_WAV_LOADER_STATE_INITIAL,
|
||||
ASSET_WAV_LOADER_STATE_READ_HEADER,
|
||||
ASSET_WAV_LOADER_STATE_DONE
|
||||
} assetwavloaderstate_t;
|
||||
|
||||
/** Per-slot scratch data used while the wav file is loading. */
|
||||
typedef struct {
|
||||
assetwavloaderstate_t state;
|
||||
} assetwavloaderloading_t;
|
||||
|
||||
/**
|
||||
* Parsed metadata for a WAV asset - only the RIFF/WAVE/fmt/data chunk
|
||||
* headers are ever read; the PCM sample data itself is never loaded here
|
||||
* (see @ref assetWavParseHeader). Playback reads sample data directly
|
||||
* from the archive on demand instead - see audiostreampcm.h.
|
||||
*/
|
||||
typedef struct {
|
||||
/** Sample rate of the PCM data, in Hz. */
|
||||
uint32_t sampleRate;
|
||||
|
||||
/** Number of interleaved channels in the PCM data. */
|
||||
uint8_t channels;
|
||||
|
||||
/**
|
||||
* Bits per sample of the *source* file data - 16 or 24 (see
|
||||
* assetWavParseHeader). Playback always reads 16-bit samples out via
|
||||
* audioStreamPcmRead() regardless of this - a 24-bit source is
|
||||
* truncated to 16-bit there, since none of this project's audio
|
||||
* backends (PSP/Dolphin hardware, SDL2 on Linux) accept anything wider.
|
||||
*/
|
||||
uint8_t bitsPerSample;
|
||||
|
||||
/** Byte offset from the start of the file to the first PCM sample. */
|
||||
size_t dataOffset;
|
||||
|
||||
/** Size of the PCM data, in bytes. */
|
||||
size_t dataSize;
|
||||
} assetwavfile_t;
|
||||
|
||||
/** Convenience alias - the loaded output type of a wav asset entry. */
|
||||
typedef assetwavfile_t assetwavoutput_t;
|
||||
|
||||
/**
|
||||
* Asynchronous loader callback. Opens the WAV file and reads just enough of
|
||||
* it to locate and parse the `fmt ` chunk and locate (not read) the `data`
|
||||
* chunk - see @ref assetWavParseHeader. All I/O happens here so the main
|
||||
* thread is not blocked. Sets entry state to `ASSET_ENTRY_STATE_PENDING_SYNC`
|
||||
* on success or `ASSET_ENTRY_STATE_ERROR` on failure.
|
||||
*
|
||||
* @param loading The loading slot for this asset entry.
|
||||
* @return OK on success, error otherwise.
|
||||
*/
|
||||
errorret_t assetWavLoaderAsync(assetloading_t *loading);
|
||||
|
||||
/**
|
||||
* Synchronous loader callback. Confirms the async phase completed and marks
|
||||
* the entry as `ASSET_ENTRY_STATE_LOADED`.
|
||||
*
|
||||
* @param loading The loading slot for this asset entry.
|
||||
* @return OK on success, error otherwise.
|
||||
*/
|
||||
errorret_t assetWavLoaderSync(assetloading_t *loading);
|
||||
|
||||
/**
|
||||
* Dispose callback. The wav asset owns no allocations of its own (its data
|
||||
* chunk is read directly from the archive by whichever streams are playing
|
||||
* it, each through their own handle - see audiostreampcm.h), so this is
|
||||
* currently a no-op beyond the standard asserts.
|
||||
*
|
||||
* @param entry The asset entry to dispose.
|
||||
* @return OK on success, error otherwise.
|
||||
*/
|
||||
errorret_t assetWavDispose(assetentry_t *entry);
|
||||
|
||||
/**
|
||||
* Parses a RIFF/WAVE file's chunk structure from an already-open asset
|
||||
* file, reading only chunk headers (and the small `fmt ` chunk body) -
|
||||
* every other chunk, including `data`'s actual sample bytes, is skipped
|
||||
* over via `assetFileRead(file, NULL, size)` rather than read into memory,
|
||||
* so this never buffers the (potentially large) PCM payload.
|
||||
*
|
||||
* Stops as soon as the `data` chunk header is found, recording its file
|
||||
* offset and declared size in `wavFile` without reading any of its bytes -
|
||||
* the file is left positioned at the start of the PCM data.
|
||||
*
|
||||
* Only PCM (audio format 1), 16- or 24-bit-per-sample WAV data is
|
||||
* supported - see audiostreampcm.h's audioStreamPcmRead() for how a
|
||||
* 24-bit source gets truncated to the 16-bit output every platform
|
||||
* backend expects. Requires the `fmt ` chunk to appear before `data`, per
|
||||
* the WAV spec's recommended ordering.
|
||||
*
|
||||
* @param file An open asset file, positioned at the start of the WAV data.
|
||||
* @param wavFile Struct whose fields will be filled in.
|
||||
* @return OK on success, error if the file is malformed or an unsupported
|
||||
* format.
|
||||
*/
|
||||
errorret_t assetWavParseHeader(
|
||||
assetfile_t *file,
|
||||
assetwavfile_t *wavFile
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
audio.c
|
||||
audiostream.c
|
||||
audiostreampcm.c
|
||||
audiostreammp3.c
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "audio.h"
|
||||
#include "util/memory.h"
|
||||
#include "assert/assert.h"
|
||||
|
||||
audio_t AUDIO;
|
||||
|
||||
errorret_t audioInit() {
|
||||
memoryZero(&AUDIO, sizeof(audio_t));
|
||||
|
||||
errorChain(audioPlatformInit());
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
audiostream_t * audioAquireStream(assetentry_t *asset) {
|
||||
assertNotNull(asset, "Asset cannot be NULL.");
|
||||
// Same check audioStreamInit() makes - see its own comment on why this
|
||||
// is an assert (a programmer error, not untrusted data). Checking here
|
||||
// too catches the mistake as early as possible, before any stream slot
|
||||
// is handed out for it.
|
||||
assertTrue(
|
||||
asset->type == ASSET_LOADER_TYPE_WAV,
|
||||
"Unsupported asset type for an audio stream."
|
||||
);
|
||||
|
||||
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
|
||||
audiostream_t *stream = &AUDIO.streams[i];
|
||||
if(stream->type == AUDIO_STREAM_TYPE_NULL) {
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
errorret_t audioUpdate() {
|
||||
errorChain(audioPlatformUpdate());
|
||||
|
||||
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
|
||||
audiostream_t *stream = &AUDIO.streams[i];
|
||||
errorChain(audioStreamUpdate(stream));
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t audioDispose() {
|
||||
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
|
||||
audiostream_t *stream = &AUDIO.streams[i];
|
||||
errorChain(audioStreamDispose(stream));
|
||||
}
|
||||
|
||||
errorChain(audioPlatformDispose());
|
||||
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "dusk.h"
|
||||
#include "audio/audiostream.h"
|
||||
#include "audio/audioplatform.h"
|
||||
|
||||
#ifndef audioPlatformInit
|
||||
#error "audioPlatformInit is not defined"
|
||||
#endif
|
||||
|
||||
#ifndef audioPlatformUpdate
|
||||
#error "audioPlatformUpdate is not defined"
|
||||
#endif
|
||||
|
||||
#ifndef audioPlatformDispose
|
||||
#error "audioPlatformDispose is not defined"
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
audiostream_t streams[AUDIO_STREAMS_MAX];
|
||||
} audio_t;
|
||||
|
||||
extern audio_t AUDIO;
|
||||
|
||||
/**
|
||||
* Initializes the audio subsystem.
|
||||
*
|
||||
* @return Error indicating success or failure of the operation.
|
||||
*/
|
||||
errorret_t audioInit();
|
||||
|
||||
/**
|
||||
* Aquires an available audio stream for playing the given asset. Can return
|
||||
* NULL if there is no available stream slot.
|
||||
*
|
||||
* The returned stream is not yet configured to play `asset` - call
|
||||
* audioStreamInit(stream, asset) next.
|
||||
*
|
||||
* @param asset The asset the caller intends to play - validated eagerly so
|
||||
* an unsupported asset type is caught here rather than only
|
||||
* once audioStreamInit() is called.
|
||||
* @return Pointer to an available audio stream, or NULL if none are free.
|
||||
*/
|
||||
audiostream_t * audioAquireStream(assetentry_t *asset);
|
||||
|
||||
/**
|
||||
* Updates the audio subsystem, updating every active stream. Should be
|
||||
* called once per frame.
|
||||
*
|
||||
* @return Error indicating success or failure of the operation.
|
||||
*/
|
||||
errorret_t audioUpdate();
|
||||
|
||||
/**
|
||||
* Disposes the audio subsystem, stopping and disposing every active stream.
|
||||
*
|
||||
* @return Error indicating success or failure of the operation.
|
||||
*/
|
||||
errorret_t audioDispose();
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "audiostream.h"
|
||||
#include "assert/assert.h"
|
||||
|
||||
errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertNotNull(asset, "Asset cannot be NULL.");
|
||||
assertTrue(
|
||||
asset->state == ASSET_ENTRY_STATE_LOADED,
|
||||
"Asset must be loaded before it can back an audio stream."
|
||||
);
|
||||
// Only WAV (-> PCM) assets can back an audio stream today - a caller
|
||||
// passing the wrong kind of asset entry is a programmer error, not
|
||||
// something that can happen from untrusted data, so this is an assert
|
||||
// rather than an errorThrow (see feedback_assert_vs_error convention).
|
||||
assertTrue(
|
||||
asset->type == ASSET_LOADER_TYPE_WAV,
|
||||
"Unsupported asset type for an audio stream."
|
||||
);
|
||||
|
||||
stream->state = 0;
|
||||
stream->volume = 0xFF;
|
||||
stream->directionality = AUDIO_STREAM_CENTER;
|
||||
stream->loopStart = -1;
|
||||
stream->loopTo = 0;
|
||||
stream->duration = 0;
|
||||
stream->startFrame = 0;
|
||||
stream->seeking = false;
|
||||
stream->user = NULL;
|
||||
stream->onLoop = NULL;
|
||||
stream->onEnd = NULL;
|
||||
stream->loopCount = 0;
|
||||
stream->lastLoopCount = 0;
|
||||
|
||||
// Locked for as long as the stream is in use (see audiostream_t.asset's
|
||||
// own comment) - released in audioStreamDispose().
|
||||
stream->asset = asset;
|
||||
assetEntryLock(asset);
|
||||
|
||||
// Type-specific setup (audioStreamPcmInit() / audioStreamMp3Init(), once
|
||||
// MP3 exists) determines stream->type and asks the platform
|
||||
// implementation to set up its state.
|
||||
errorret_t ret = audioStreamPcmInit(stream);
|
||||
if(errorIsNotOk(ret)) {
|
||||
assetEntryUnlock(asset);
|
||||
stream->asset = NULL;
|
||||
errorChain(ret);
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
void audioStreamPlay(audiostream_t *stream) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
stream->state |= AUDIO_STREAM_STATE_PLAYING;
|
||||
}
|
||||
|
||||
void audioStreamPause(audiostream_t *stream) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
stream->state &= ~AUDIO_STREAM_STATE_PLAYING;
|
||||
}
|
||||
|
||||
void audioStreamSetPosition(audiostream_t *stream, const float_t position) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
|
||||
// Wrap into [0, duration).
|
||||
float_t t = position;
|
||||
while(t < 0) t += stream->duration;
|
||||
while(t >= stream->duration) t -= stream->duration;
|
||||
|
||||
stream->startFrame = (size_t) (t * stream->pcm.sampleRate);
|
||||
stream->seeking = true;
|
||||
|
||||
// Force the next audioStreamUpdate() to re-buffer from startFrame instead
|
||||
// of continuing whatever was already buffered - see the platform Buffer()
|
||||
// implementations for how each one applies startFrame; PSP is the one
|
||||
// exception (see audioStreamSetPosition's own doc comment).
|
||||
stream->state &= ~AUDIO_STREAM_STATE_BUFFERED;
|
||||
}
|
||||
|
||||
void audioStreamSetLoopPoints(
|
||||
audiostream_t *stream,
|
||||
const float_t loopStart,
|
||||
const float_t loopTo
|
||||
) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertTrue(
|
||||
loopStart < 0 || loopTo < loopStart,
|
||||
"loopTo must be before loopStart."
|
||||
);
|
||||
|
||||
stream->loopStart = loopStart;
|
||||
stream->loopTo = loopTo;
|
||||
}
|
||||
|
||||
void audioStreamGetPanFactors(
|
||||
const audiostream_t *stream,
|
||||
float_t *outLeft,
|
||||
float_t *outRight
|
||||
) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertNotNull(outLeft, "outLeft cannot be NULL.");
|
||||
assertNotNull(outRight, "outRight cannot be NULL.");
|
||||
|
||||
// directionality is an int8_t clamped to AUDIO_STREAM_LEFT..RIGHT
|
||||
// (-128..127) by its own type, so pan is always within [-1.0, 0.992] -
|
||||
// no further clamping needed.
|
||||
const float_t pan = (float_t) stream->directionality / 128.0f;
|
||||
|
||||
*outLeft = pan > 0 ? (1.0f - pan) : 1.0f;
|
||||
*outRight = pan < 0 ? (1.0f + pan) : 1.0f;
|
||||
}
|
||||
|
||||
void audioStreamSetVolume(audiostream_t *stream, const uint8_t volume) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
stream->volume = volume;
|
||||
// TODO: Do I need to update the device output? PSP may require this
|
||||
}
|
||||
|
||||
void audioStreamSetDirectionality(
|
||||
audiostream_t *stream,
|
||||
const int8_t directionality
|
||||
) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
stream->directionality = directionality;
|
||||
// TODO: Need to update internal decoder?
|
||||
}
|
||||
|
||||
void audioStreamSetLooping(audiostream_t *stream, const bool_t looping) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
|
||||
if(looping) {
|
||||
stream->state |= AUDIO_STREAM_STATE_LOOPING;
|
||||
} else {
|
||||
stream->state &= ~AUDIO_STREAM_STATE_LOOPING;
|
||||
}
|
||||
}
|
||||
|
||||
errorret_t audioStreamUpdate(audiostream_t *stream) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
|
||||
if(stream->type == AUDIO_STREAM_TYPE_NULL) {
|
||||
errorOk();
|
||||
}
|
||||
|
||||
// Some platforms (PSP) loop entirely on their own background thread for
|
||||
// gaplessness and can only safely notify onLoop by incrementing this
|
||||
// counter from there, rather than calling onLoop directly off the main
|
||||
// thread - see loopCount's own comment. Firing it here, unconditionally,
|
||||
// catches up to the latest count in one call even if multiple loops
|
||||
// happened between two Update() calls.
|
||||
if(stream->loopCount != stream->lastLoopCount) {
|
||||
stream->lastLoopCount = stream->loopCount;
|
||||
|
||||
if(stream->onLoop != NULL) {
|
||||
stream->onLoop(stream);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Once streamed (rather than fully in-memory) sources exist, this is
|
||||
// where new data would be decoded/read in and re-buffered as playback
|
||||
// consumes it.
|
||||
// Checked in this order (finished-check before needs-buffering) so that a
|
||||
// loop restart falls straight through into re-buffering within this same
|
||||
// call, instead of leaving BUFFERED cleared for the caller to notice and
|
||||
// act on next frame - that extra frame of latency was audible as a gap
|
||||
// at every loop boundary.
|
||||
if(
|
||||
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
|
||||
(stream->state & AUDIO_STREAM_STATE_BUFFERED) &&
|
||||
audioStreamPlatformIsFinished(stream)
|
||||
) {
|
||||
if(stream->state & AUDIO_STREAM_STATE_LOOPING) {
|
||||
stream->state &= ~AUDIO_STREAM_STATE_BUFFERED;
|
||||
|
||||
// Resume from loopTo rather than the very start of the buffer - only
|
||||
// matters for platforms that reach this generic restart path at all;
|
||||
// PSP/Dolphin loop entirely on their own (thread/hardware) and never
|
||||
// report "finished" while looping, so in practice this is Linux-only.
|
||||
stream->startFrame = (size_t) (stream->loopTo * stream->pcm.sampleRate);
|
||||
|
||||
if(stream->onLoop != NULL) {
|
||||
stream->onLoop(stream);
|
||||
}
|
||||
} else {
|
||||
stream->state &= ~(AUDIO_STREAM_STATE_PLAYING | AUDIO_STREAM_STATE_BUFFERED);
|
||||
|
||||
if(stream->onEnd != NULL) {
|
||||
stream->onEnd(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(
|
||||
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
|
||||
!(stream->state & AUDIO_STREAM_STATE_BUFFERED)
|
||||
) {
|
||||
errorChain(audioStreamPlatformBuffer(stream));
|
||||
stream->state |= AUDIO_STREAM_STATE_BUFFERED;
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t audioStreamDispose(audiostream_t *stream) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
|
||||
if(stream->type != AUDIO_STREAM_TYPE_NULL) {
|
||||
errorChain(audioStreamPlatformDispose(stream));
|
||||
}
|
||||
|
||||
if(stream->type == AUDIO_STREAM_TYPE_PCM) {
|
||||
errorChain(audioStreamPcmDispose(stream));
|
||||
}
|
||||
|
||||
if(stream->asset != NULL) {
|
||||
assetEntryUnlock(stream->asset);
|
||||
stream->asset = NULL;
|
||||
}
|
||||
|
||||
stream->type = AUDIO_STREAM_TYPE_NULL;
|
||||
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "audio/audiostreampcm.h"
|
||||
#include "audio/audiostreammp3.h"
|
||||
#include "audio/audiostreamplatform.h"
|
||||
#include "asset/loader/assetentry.h"
|
||||
|
||||
#ifndef audioStreamPlatformInit
|
||||
#error "audioStreamPlatformInit is not defined"
|
||||
#endif
|
||||
|
||||
#ifndef audioStreamPlatformDispose
|
||||
#error "audioStreamPlatformDispose is not defined"
|
||||
#endif
|
||||
|
||||
#ifndef audioStreamPlatformBuffer
|
||||
#error "audioStreamPlatformBuffer is not defined"
|
||||
#endif
|
||||
|
||||
#ifndef audioStreamPlatformIsFinished
|
||||
#error "audioStreamPlatformIsFinished is not defined"
|
||||
#endif
|
||||
|
||||
#define AUDIO_STREAMS_MAX 8
|
||||
#define AUDIO_STREAM_STATE_PLAYING (1 << 0)
|
||||
#define AUDIO_STREAM_STATE_LOOPING (1 << 1)
|
||||
#define AUDIO_STREAM_STATE_BUFFERED (1 << 2)
|
||||
#define AUDIO_STREAM_CENTER 0
|
||||
#define AUDIO_STREAM_LEFT -128
|
||||
#define AUDIO_STREAM_RIGHT 127
|
||||
|
||||
typedef enum {
|
||||
AUDIO_STREAM_TYPE_NULL,
|
||||
AUDIO_STREAM_TYPE_PCM,
|
||||
AUDIO_STREAM_TYPE_MP3,
|
||||
AUDIO_STREAM_TYPE_COUNT
|
||||
} audistreamtype_t;
|
||||
|
||||
typedef struct audiostream_s audiostream_t;
|
||||
|
||||
typedef struct audiostream_s {
|
||||
// Used for aquiring new data.
|
||||
audistreamtype_t type;
|
||||
|
||||
// What state the stream is in.
|
||||
uint8_t state;
|
||||
|
||||
// The asset backing this stream's data (e.g. a WAV file) - determines
|
||||
// `type` (see audioStreamInit()) and is read from on demand by the
|
||||
// type-specific module (e.g. audiostreampcm.h) rather than ever being
|
||||
// fully decoded into memory up front. The stream holds its own lock on
|
||||
// this entry (assetEntryLock()/assetEntryUnlock()) for as long as it's
|
||||
// in use, independent of whatever lock(s) the caller that requested
|
||||
// playback may also be holding.
|
||||
assetentry_t *asset;
|
||||
|
||||
// Loudness. Can only be 0 to 0xFF
|
||||
uint8_t volume;
|
||||
|
||||
// In stereo space, where do we send the audio.
|
||||
// TODO: Can we use a 3D vector + Pro Logic II?
|
||||
int8_t directionality;
|
||||
|
||||
// In seconds, where the loop segment ends and playback jumps back to
|
||||
// loopTo - -1 (the default) means "at the end of the stream", i.e. loop
|
||||
// the whole buffer. Set via audioStreamSetLoopPoints().
|
||||
float_t loopStart;
|
||||
|
||||
// In seconds, where a loop jumps back to once it reaches loopStart.
|
||||
// Defaults to 0 (start of stream). Set via audioStreamSetLoopPoints().
|
||||
float_t loopTo;
|
||||
|
||||
// Cached duration of the stream in seconds.
|
||||
float_t duration;
|
||||
|
||||
// Frame offset the next platform Buffer() call should start playback
|
||||
// from - set by audioStreamSetPosition() (an explicit seek) and by
|
||||
// audioStreamUpdate() itself (to loopTo's frame offset, for platforms
|
||||
// that re-enter Buffer() on every loop restart rather than looping
|
||||
// natively). Each platform's Buffer()-invoking entry point must read and
|
||||
// reset this to 0 synchronously, in the same call that decided to
|
||||
// (re)buffer - not later/asynchronously (e.g. from a feeder thread),
|
||||
// since audioStreamUpdate() may already have moved on to something else
|
||||
// that touches this field by the time an async reader gets to it.
|
||||
size_t startFrame;
|
||||
|
||||
// True when startFrame came from an explicit audioStreamSetPosition()
|
||||
// seek rather than a natural loop restart. Platforms whose Buffer() call
|
||||
// can leave previously-queued audio still playing underneath the new
|
||||
// pass (currently just Linux's SDL queue, kept deliberately overlapping
|
||||
// across a loop restart to avoid a gap) need this to tell "jump now,
|
||||
// discarding whatever's still playing" (seek) apart from "let the old
|
||||
// tail keep playing while the new pass queues underneath it" (loop
|
||||
// restart) - both look identical as just "a pending startFrame"
|
||||
// otherwise. Consumed (reset to false) the same way as startFrame.
|
||||
bool_t seeking;
|
||||
|
||||
// Callbacks
|
||||
void *user;
|
||||
void (*onLoop)(audiostream_t *stream);
|
||||
void (*onEnd)(audiostream_t *stream);
|
||||
|
||||
// Incremented by platform code (from whatever thread/context it runs in)
|
||||
// each time a loop happens, instead of calling onLoop directly - onLoop
|
||||
// may do arbitrary, possibly-slow work (console printing, game logic),
|
||||
// which is only safe to run from the main thread inside audioStreamUpdate().
|
||||
// Calling it straight from a real-time audio thread risks starving the
|
||||
// hardware buffer if it takes too long - confirmed as the real cause of
|
||||
// a loud crackle on PSP once its feeder thread called onLoop inline.
|
||||
volatile uint32_t loopCount;
|
||||
|
||||
// audioStreamUpdate()'s own record of the last loopCount it fired
|
||||
// onLoop for - only ever touched from the main thread.
|
||||
uint32_t lastLoopCount;
|
||||
|
||||
// Stream type specific data.
|
||||
union {
|
||||
audiostreampcm_t pcm;
|
||||
audiostreammp3_t mp3;
|
||||
};
|
||||
|
||||
// Platform-specific playback state (e.g. SDL2 device, PSP channel, ansnd
|
||||
// voice). Defined by each platform's audiostreamplatform.h.
|
||||
audiostreamplatform_t platform;
|
||||
} audiostream_t;
|
||||
|
||||
/**
|
||||
* Initializes an audio stream to play the given asset, determining the
|
||||
* stream's type from the asset's loader type (e.g. a WAV asset becomes an
|
||||
* AUDIO_STREAM_TYPE_PCM stream) and dispatching to that type's own setup
|
||||
* (e.g. audioStreamPcmInit()). Does not begin playback; call
|
||||
* audioStreamPlay() once this returns.
|
||||
*
|
||||
* The stream takes its own lock on `asset` (see audiostream_t.asset's own
|
||||
* comment), released by audioStreamDispose() - the asset must already be
|
||||
* loaded (ASSET_ENTRY_STATE_LOADED) when this is called.
|
||||
*
|
||||
* @param stream The audio stream to initialize.
|
||||
* @param asset The loaded asset to play - its type must be one this
|
||||
* function supports (currently only ASSET_LOADER_TYPE_WAV).
|
||||
* @return Error indicating success or failure.
|
||||
*/
|
||||
errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset);
|
||||
|
||||
/**
|
||||
* Begins or resumes playback of the given audio stream.
|
||||
*
|
||||
* @param stream The audio stream to play.
|
||||
*/
|
||||
void audioStreamPlay(audiostream_t *stream);
|
||||
|
||||
/**
|
||||
* Pauses playback of the given audio stream. Playback position is retained,
|
||||
* so audioStreamPlay() resumes from the same point.
|
||||
*
|
||||
* @param stream The audio stream to pause.
|
||||
*/
|
||||
void audioStreamPause(audiostream_t *stream);
|
||||
|
||||
/**
|
||||
* Seeks the given audio stream to the given position, in seconds, wrapping
|
||||
* into range if the position is outside [0, duration). Takes effect on the
|
||||
* next audioStreamUpdate() call for platforms that re-buffer per frame
|
||||
* (e.g. Linux); on PSP, which manages an entire playback pass on its own
|
||||
* feeder thread once started, a seek only takes effect the next time the
|
||||
* stream begins playing from a stopped state, not instantaneously mid-pass.
|
||||
*
|
||||
* @param stream The audio stream to seek.
|
||||
* @param position The new playback position, in seconds.
|
||||
*/
|
||||
void audioStreamSetPosition(audiostream_t *stream, const float_t position);
|
||||
|
||||
/**
|
||||
* Sets the loop region for the given audio stream: once playback reaches
|
||||
* loopStart, it jumps back to loopTo instead of continuing (or stopping,
|
||||
* if not looping). Has no effect unless looping is also enabled via
|
||||
* audioStreamSetLooping().
|
||||
*
|
||||
* @param stream The audio stream to update.
|
||||
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
|
||||
* the whole stream (the default).
|
||||
* @param loopTo Where the loop segment starts, in seconds.
|
||||
*/
|
||||
void audioStreamSetLoopPoints(
|
||||
audiostream_t *stream,
|
||||
const float_t loopStart,
|
||||
const float_t loopTo
|
||||
);
|
||||
|
||||
/**
|
||||
* Computes normalized left/right pan factors (0..1) for the given audio
|
||||
* stream's directionality. Callers multiply these by their own
|
||||
* platform-specific base volume representation.
|
||||
*
|
||||
* @param stream The audio stream to read directionality from.
|
||||
* @param outLeft Set to the left channel's pan factor.
|
||||
* @param outRight Set to the right channel's pan factor.
|
||||
*/
|
||||
void audioStreamGetPanFactors(
|
||||
const audiostream_t *stream,
|
||||
float_t *outLeft,
|
||||
float_t *outRight
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the playback volume of the given audio stream.
|
||||
*
|
||||
* @param stream The audio stream to update.
|
||||
* @param volume The new volume, from 0 (silent) to 0xFF (loudest).
|
||||
*/
|
||||
void audioStreamSetVolume(audiostream_t *stream, const uint8_t volume);
|
||||
|
||||
/**
|
||||
* Sets the stereo directionality (panning) of the given audio stream.
|
||||
*
|
||||
* @param stream The audio stream to update.
|
||||
* @param directionality The new panning value, from AUDIO_STREAM_LEFT to
|
||||
* AUDIO_STREAM_RIGHT.
|
||||
*/
|
||||
void audioStreamSetDirectionality(
|
||||
audiostream_t *stream,
|
||||
const int8_t directionality
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets whether the given audio stream loops back to loopTo (or the start
|
||||
* of the buffer, by default - see audioStreamSetLoopPoints()) when it
|
||||
* reaches loopStart (or the end of the buffer), rather than stopping and
|
||||
* firing onEnd.
|
||||
*
|
||||
* @param stream The audio stream to update.
|
||||
* @param looping Whether the stream should loop.
|
||||
*/
|
||||
void audioStreamSetLooping(audiostream_t *stream, const bool_t looping);
|
||||
|
||||
/**
|
||||
* Updates the given audio stream, decoding new data and advancing playback
|
||||
* as needed. Should be called every frame for every active stream.
|
||||
*
|
||||
* @param stream The audio stream to update.
|
||||
* @return Error indicating success or failure.
|
||||
*/
|
||||
errorret_t audioStreamUpdate(audiostream_t *stream);
|
||||
|
||||
/**
|
||||
* Disposes the audio stream, stopping playback and releasing any resources
|
||||
* associated with it.
|
||||
*
|
||||
* @param stream The audio stream to dispose.
|
||||
* @return Error indicating success or failure.
|
||||
*/
|
||||
errorret_t audioStreamDispose(audiostream_t *stream);
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "audiostreammp3.h"
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
|
||||
#define CONSOLE_LINE_MAX 512
|
||||
#define CONSOLE_HISTORY_MAX 16
|
||||
#define CONSOLE_EXEC_BUFFER_MAX 32
|
||||
typedef struct {
|
||||
void *empty;
|
||||
} audiostreammp3_t;
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "audiostreampcm.h"
|
||||
#include "audiostream.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/math.h"
|
||||
#include "util/memory.h"
|
||||
|
||||
errorret_t audioStreamPcmInit(audiostream_t *stream) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertNotNull(stream->asset, "Stream must have an asset assigned.");
|
||||
assertTrue(
|
||||
stream->asset->type == ASSET_LOADER_TYPE_WAV,
|
||||
"Asset is not a WAV file."
|
||||
);
|
||||
|
||||
assetwavfile_t *wav = &stream->asset->data.wav;
|
||||
|
||||
stream->type = AUDIO_STREAM_TYPE_PCM;
|
||||
stream->pcm.sampleRate = wav->sampleRate;
|
||||
stream->pcm.channels = wav->channels;
|
||||
stream->duration = (
|
||||
(float_t) audioStreamPcmGetTotalFrames(stream) /
|
||||
(float_t) wav->sampleRate
|
||||
);
|
||||
|
||||
// Each stream opens its own independent handle to the same underlying
|
||||
// asset file, rather than sharing a single handle on the asset entry -
|
||||
// multiple streams playing the same asset concurrently (e.g. two
|
||||
// simultaneous plays of the same sound effect) would otherwise fight
|
||||
// over one shared read position. This isn't the final answer for that
|
||||
// (a shared decode/cache layer would scale better than N independent
|
||||
// decompression streams of the same data), but it's a correct one for
|
||||
// now - a problem to revisit once it actually matters.
|
||||
errorChain(assetFileInit(
|
||||
&stream->pcm.file, stream->asset->name, NULL, NULL
|
||||
));
|
||||
errorChain(assetFileOpen(&stream->pcm.file));
|
||||
errorChain(assetFileRead(&stream->pcm.file, NULL, wav->dataOffset));
|
||||
|
||||
errorChain(audioStreamPlatformInit(stream));
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t audioStreamPcmDispose(audiostream_t *stream) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||
|
||||
errorChain(assetFileClose(&stream->pcm.file));
|
||||
errorChain(assetFileDispose(&stream->pcm.file));
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||
|
||||
const assetwavfile_t *wav = &stream->asset->data.wav;
|
||||
const size_t sourceFrameSize = (
|
||||
stream->pcm.channels * (wav->bitsPerSample / 8)
|
||||
);
|
||||
return wav->dataSize / sourceFrameSize;
|
||||
}
|
||||
|
||||
errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||
|
||||
assetwavfile_t *wav = &stream->asset->data.wav;
|
||||
const size_t sourceFrameSize = (
|
||||
stream->pcm.channels * (wav->bitsPerSample / 8)
|
||||
);
|
||||
const size_t targetByte = wav->dataOffset + (frame * sourceFrameSize);
|
||||
|
||||
assertTrue(
|
||||
frame * sourceFrameSize <= wav->dataSize,
|
||||
"Seek frame is beyond the end of the PCM data."
|
||||
);
|
||||
|
||||
const size_t currentByte = (size_t) stream->pcm.file.position;
|
||||
if(targetByte < currentByte) {
|
||||
// Only a full rewind can move a read cursor earlier once it's already
|
||||
// advanced past a point - a compressed archive entry can't be decoded
|
||||
// backward. See this function's own doc comment for the performance
|
||||
// implications of a deep loopTo.
|
||||
errorChain(assetFileRewind(&stream->pcm.file));
|
||||
errorChain(assetFileRead(&stream->pcm.file, NULL, targetByte));
|
||||
} else if(targetByte > currentByte) {
|
||||
errorChain(assetFileRead(
|
||||
&stream->pcm.file, NULL, targetByte - currentByte
|
||||
));
|
||||
}
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t audioStreamPcmRead(
|
||||
audiostream_t *stream,
|
||||
int16_t *buffer,
|
||||
const size_t frameCount,
|
||||
size_t *outFramesRead
|
||||
) {
|
||||
assertNotNull(stream, "Stream cannot be NULL.");
|
||||
assertNotNull(buffer, "Buffer cannot be NULL.");
|
||||
assertNotNull(outFramesRead, "outFramesRead cannot be NULL.");
|
||||
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||
|
||||
assetwavfile_t *wav = &stream->asset->data.wav;
|
||||
const size_t channels = stream->pcm.channels;
|
||||
const size_t sourceSampleBytes = wav->bitsPerSample / 8;
|
||||
const size_t sourceFrameSize = channels * sourceSampleBytes;
|
||||
const size_t dataEndByte = wav->dataOffset + wav->dataSize;
|
||||
const size_t currentByte = (size_t) stream->pcm.file.position;
|
||||
|
||||
const size_t bytesAvailable = (
|
||||
currentByte < dataEndByte ? dataEndByte - currentByte : 0
|
||||
);
|
||||
const size_t framesAvailable = bytesAvailable / sourceFrameSize;
|
||||
const size_t framesToRead = mathMin(frameCount, framesAvailable);
|
||||
|
||||
if(framesToRead == 0) {
|
||||
*outFramesRead = 0;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
if(sourceSampleBytes == sizeof(int16_t)) {
|
||||
// Output format already matches the source - read straight through.
|
||||
errorChain(assetFileRead(
|
||||
&stream->pcm.file, buffer, framesToRead * sourceFrameSize
|
||||
));
|
||||
} else {
|
||||
// 24-bit source (the only other width assetWavParseHeader() accepts) -
|
||||
// read the raw 3-byte samples into a scratch buffer, then truncate
|
||||
// each down to 16-bit, since none of PSP/Dolphin's audio hardware (or
|
||||
// SDL2 on Linux) accepts anything wider - see assetwavfile_t's own
|
||||
// comment on bitsPerSample.
|
||||
assertTrue(sourceSampleBytes == 3, "Unsupported PCM sample width.");
|
||||
|
||||
const size_t rawBytes = framesToRead * sourceFrameSize;
|
||||
uint8_t *raw = memoryAllocate(rawBytes);
|
||||
errorret_t ret = assetFileRead(&stream->pcm.file, raw, rawBytes);
|
||||
if(errorIsNotOk(ret)) {
|
||||
memoryFree(raw);
|
||||
errorChain(ret);
|
||||
}
|
||||
|
||||
// 24-bit PCM samples are little-endian two's complement - the top two
|
||||
// bytes of each (indices 1 and 2) already form that same value
|
||||
// truncated to 16-bit (equivalent to an arithmetic right-shift by 8
|
||||
// bits, which preserves the sign correctly since byte 2 carries it).
|
||||
const size_t sampleCount = framesToRead * channels;
|
||||
for(size_t i = 0; i < sampleCount; i++) {
|
||||
const uint8_t *sample = raw + (i * 3);
|
||||
buffer[i] = (int16_t) (sample[1] | (sample[2] << 8));
|
||||
}
|
||||
|
||||
memoryFree(raw);
|
||||
}
|
||||
|
||||
*outFramesRead = framesToRead;
|
||||
errorOk();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
#include "asset/assetfile.h"
|
||||
|
||||
typedef struct audiostream_s audiostream_t;
|
||||
|
||||
typedef struct {
|
||||
// Sample rate of the stream's data, in Hz.
|
||||
uint32_t sampleRate;
|
||||
|
||||
// Number of interleaved channels in the stream's data.
|
||||
uint8_t channels;
|
||||
|
||||
// This stream's own private handle into its asset's underlying file -
|
||||
// deliberately not shared with any other stream reading the same asset
|
||||
// (see audioStreamPcmInit's own comment for why). Its read cursor is
|
||||
// what audioStreamPcmRead()/audioStreamPcmSeek() operate on.
|
||||
assetfile_t file;
|
||||
} audiostreampcm_t;
|
||||
|
||||
/**
|
||||
* Configures the given PCM audio stream from its already-assigned asset
|
||||
* (see audiostream_t.asset - set by audioStreamInit(), which is what
|
||||
* should be calling this, not application code directly) rather than
|
||||
* taking raw decoded data: sampleRate/channels/duration are read from the
|
||||
* asset's parsed WAV header, and a private file handle is opened for
|
||||
* reading sample data on demand as playback consumes it (see
|
||||
* audioStreamPcmRead()) - the PCM data itself is never read into memory
|
||||
* all at once, so this works the same regardless of how long the
|
||||
* underlying clip is.
|
||||
*
|
||||
* Must be called after stream->asset is set and before audioStreamPlay().
|
||||
*
|
||||
* @param stream The audio stream to configure.
|
||||
* @return Error indicating success or failure.
|
||||
*/
|
||||
errorret_t audioStreamPcmInit(audiostream_t *stream);
|
||||
|
||||
/**
|
||||
* Disposes the given PCM audio stream's own private file handle.
|
||||
*
|
||||
* @param stream The audio stream to dispose.
|
||||
* @return Error indicating success or failure.
|
||||
*/
|
||||
errorret_t audioStreamPcmDispose(audiostream_t *stream);
|
||||
|
||||
/**
|
||||
* Returns the total number of frames (one sample per channel) available
|
||||
* in the stream's underlying PCM data - NOT simply the asset's declared
|
||||
* byte size divided by a 16-bit frame size, since the source data isn't
|
||||
* always 16-bit even though audioStreamPcmRead() always produces 16-bit
|
||||
* output (see assetwavfile_t.bitsPerSample). Platform backends should use
|
||||
* this rather than computing frame counts from dataSize themselves.
|
||||
*
|
||||
* @param stream The audio stream to query. Must be AUDIO_STREAM_TYPE_PCM.
|
||||
* @return The total number of frames available.
|
||||
*/
|
||||
size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream);
|
||||
|
||||
/**
|
||||
* Seeks the stream's private read cursor to the given frame offset
|
||||
* (relative to the start of the PCM data - the same units as
|
||||
* audiostream_t's startFrame/loopStart/loopTo, once converted from
|
||||
* seconds). Seeking backward re-reads from the start of the underlying
|
||||
* asset file (see assetFileRewind()) since a compressed archive entry can
|
||||
* only be decoded forward - this makes a loop with a deep loopTo more
|
||||
* expensive to restart than one near the start, which is a real
|
||||
* performance caveat, not just a theoretical one, for anything backed by
|
||||
* a compressed (not stored) asset archive entry.
|
||||
*
|
||||
* @param stream The audio stream to seek. Must be AUDIO_STREAM_TYPE_PCM.
|
||||
* @param frame Frame offset to seek to, relative to the start of the PCM
|
||||
* data.
|
||||
* @return Error indicating success or failure.
|
||||
*/
|
||||
errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame);
|
||||
|
||||
/**
|
||||
* Reads up to frameCount frames of PCM sample data from the stream's
|
||||
* current read position, advancing it by however many frames were
|
||||
* actually read. Reads fewer than frameCount (down to zero) once the
|
||||
* underlying asset's PCM data is exhausted, rather than erroring - it's
|
||||
* up to the caller (platform code, which already knows about loop points)
|
||||
* to request no more than what it wants read from within the current
|
||||
* loop segment or the true end of the clip.
|
||||
*
|
||||
* @param stream The audio stream to read from. Must be AUDIO_STREAM_TYPE_PCM.
|
||||
* @param buffer Destination buffer, sized for at least frameCount frames.
|
||||
* @param frameCount Maximum number of frames to read.
|
||||
* @param outFramesRead Set to the number of frames actually read.
|
||||
* @return Error indicating success or failure.
|
||||
*/
|
||||
errorret_t audioStreamPcmRead(
|
||||
audiostream_t *stream,
|
||||
int16_t *buffer,
|
||||
const size_t frameCount,
|
||||
size_t *outFramesRead
|
||||
);
|
||||
@@ -17,11 +17,9 @@ console_t CONSOLE;
|
||||
|
||||
void consoleInit(void) {
|
||||
memoryZero(&CONSOLE, sizeof(console_t));
|
||||
CONSOLE.visible = false;
|
||||
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
// CONSOLE.visible = false;
|
||||
CONSOLE.visible = true;
|
||||
threadMutexInit(&CONSOLE.printMutex);
|
||||
#endif
|
||||
}
|
||||
|
||||
void consolePrint(const char_t *message, ...) {
|
||||
@@ -32,20 +30,14 @@ void consolePrint(const char_t *message, ...) {
|
||||
int32_t len = stringFormatVA(buffer, CONSOLE_LINE_MAX, message, args);
|
||||
va_end(args);
|
||||
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
threadMutexLock(&CONSOLE.printMutex);
|
||||
#endif
|
||||
|
||||
memoryMove(
|
||||
CONSOLE.line[0],
|
||||
CONSOLE.line[1],
|
||||
(CONSOLE_HISTORY_MAX - 1) * CONSOLE_LINE_MAX
|
||||
);
|
||||
memoryCopy(CONSOLE.line[CONSOLE_HISTORY_MAX - 1], buffer, len + 1);
|
||||
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
threadMutexUnlock(&CONSOLE.printMutex);
|
||||
#endif
|
||||
|
||||
logDebug("%s\n", buffer);
|
||||
}
|
||||
@@ -61,7 +53,5 @@ void consoleUpdate(void) {
|
||||
}
|
||||
|
||||
void consoleDispose(void) {
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
threadMutexDispose(&CONSOLE.printMutex);
|
||||
#endif
|
||||
}
|
||||
@@ -6,24 +6,18 @@
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "consoledefs.h"
|
||||
#include "error/error.h"
|
||||
#include "dusk.h"
|
||||
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
#include "thread/thread.h"
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#define CONSOLE_POSIX_POLL_RATE 75
|
||||
#endif
|
||||
|
||||
#define CONSOLE_LINE_MAX 128
|
||||
#define CONSOLE_HISTORY_MAX 16
|
||||
#define CONSOLE_EXEC_BUFFER_MAX 32
|
||||
|
||||
typedef struct {
|
||||
char_t line[CONSOLE_HISTORY_MAX][CONSOLE_LINE_MAX];
|
||||
bool_t visible;
|
||||
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
threadmutex_t printMutex;
|
||||
#endif
|
||||
} console_t;
|
||||
|
||||
extern console_t CONSOLE;
|
||||
|
||||
@@ -33,13 +33,17 @@ errorret_t displayInit(void) {
|
||||
#ifdef displayPlatformInit
|
||||
errorChain(displayPlatformInit());
|
||||
#endif
|
||||
|
||||
// Set initial state
|
||||
errorChain(displaySetState((displaystate_t){ .flags = 0 }));
|
||||
|
||||
// Init the fixed textures
|
||||
errorChain(textureInit(
|
||||
&TEXTURE_WHITE, 4, 4,
|
||||
&TEXTURE_WHITE, TEXTURE_FIXED_WIDTH, TEXTURE_FIXED_HEIGHT,
|
||||
TEXTURE_FORMAT_RGBA, (texturedata_t){ .rgbaColors = TEXTURE_WHITE_PIXELS }
|
||||
));
|
||||
errorChain(textureInit(
|
||||
&TEXTURE_TEST, 4, 4,
|
||||
&TEXTURE_TEST, TEXTURE_FIXED_WIDTH, TEXTURE_FIXED_HEIGHT,
|
||||
TEXTURE_FORMAT_RGBA, (texturedata_t){ .rgbaColors = TEXTURE_TEST_PIXELS }
|
||||
));
|
||||
|
||||
@@ -51,13 +55,13 @@ errorret_t displayInit(void) {
|
||||
errorChain(capsuleInit());
|
||||
errorChain(triPrismInit());
|
||||
|
||||
// Init the subsystems
|
||||
errorChain(frameBufferInitBackBuffer());
|
||||
errorChain(spriteBatchInit());
|
||||
errorChain(textInit());
|
||||
errorChain(screenInit());
|
||||
|
||||
// Setup initial shader with default values
|
||||
|
||||
errorChain(shaderListInit());
|
||||
|
||||
errorOk();
|
||||
|
||||
@@ -76,7 +76,7 @@ errorret_t spriteBatchBuffer(
|
||||
|
||||
// Buffer to the mesh vertices.
|
||||
spriteBatchBufferToMesh(
|
||||
sprites, batchCount, v, batchCount * QUAD_VERTEX_COUNT
|
||||
sprites + (count - remaining), batchCount, v, batchCount * QUAD_VERTEX_COUNT
|
||||
);
|
||||
SPRITEBATCH.spriteCount += batchCount;
|
||||
remaining -= batchCount;
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
text.c
|
||||
font.c
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "font.h"
|
||||
#include "util/memory.h"
|
||||
#include "util/math.h"
|
||||
#include "display/color.h"
|
||||
|
||||
font_t FONT_DEFAULT;
|
||||
static texture_t FONT_DEFAULT_TEXTURE;
|
||||
static tileset_t FONT_DEFAULT_TILESET;
|
||||
|
||||
const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
|
||||
FONT_DEFAULT_TILE_HEIGHT
|
||||
] = {
|
||||
{ 0x00, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x10, 0x00, 0x00 }, // !
|
||||
{ 0x00, 0x14, 0x14, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // "
|
||||
{ 0x00, 0x14, 0x14, 0x3E, 0x14, 0x3E, 0x14, 0x14, 0x00, 0x00 }, // #
|
||||
{ 0x00, 0x08, 0x1E, 0x28, 0x1C, 0x0A, 0x3C, 0x08, 0x00, 0x00 }, // $
|
||||
{ 0x00, 0x00, 0x22, 0x24, 0x08, 0x12, 0x22, 0x00, 0x00, 0x00 }, // %
|
||||
{ 0x00, 0x08, 0x14, 0x14, 0x1A, 0x24, 0x24, 0x1A, 0x00, 0x00 }, // &
|
||||
{ 0x00, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // '
|
||||
{ 0x00, 0x04, 0x08, 0x08, 0x08, 0x08, 0x08, 0x04, 0x00, 0x00 }, // (
|
||||
{ 0x00, 0x10, 0x08, 0x08, 0x08, 0x08, 0x08, 0x10, 0x00, 0x00 }, // )
|
||||
{ 0x00, 0x00, 0x08, 0x2A, 0x1C, 0x2A, 0x08, 0x00, 0x00, 0x00 }, // *
|
||||
{ 0x00, 0x00, 0x08, 0x08, 0x3E, 0x08, 0x08, 0x00, 0x00, 0x00 }, // +
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10, 0x20, 0x00 }, // ,
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00 }, // -
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10, 0x00, 0x00 }, // .
|
||||
{ 0x00, 0x04, 0x04, 0x08, 0x08, 0x08, 0x10, 0x10, 0x00, 0x00 }, // /
|
||||
{ 0x00, 0x1C, 0x22, 0x26, 0x2A, 0x32, 0x22, 0x1C, 0x00, 0x00 }, // 0
|
||||
{ 0x00, 0x08, 0x18, 0x08, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // 1
|
||||
{ 0x00, 0x1C, 0x22, 0x02, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // 2
|
||||
{ 0x00, 0x1C, 0x22, 0x02, 0x0C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 3
|
||||
{ 0x00, 0x22, 0x22, 0x22, 0x3E, 0x02, 0x02, 0x02, 0x00, 0x00 }, // 4
|
||||
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 5
|
||||
{ 0x00, 0x1C, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // 6
|
||||
{ 0x00, 0x3E, 0x02, 0x02, 0x04, 0x08, 0x08, 0x08, 0x00, 0x00 }, // 7
|
||||
{ 0x00, 0x1C, 0x22, 0x22, 0x1C, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // 8
|
||||
{ 0x00, 0x1C, 0x22, 0x22, 0x1E, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 9
|
||||
{ 0x00, 0x00, 0x10, 0x10, 0x00, 0x10, 0x10, 0x00, 0x00, 0x00 }, // :
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ;
|
||||
{ 0x00, 0x04, 0x08, 0x10, 0x20, 0x10, 0x08, 0x04, 0x00, 0x00 }, // <
|
||||
{ 0x00, 0x00, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00 }, // =
|
||||
{ 0x00, 0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00 }, // >
|
||||
{ 0x00, 0x1C, 0x22, 0x02, 0x04, 0x08, 0x00, 0x08, 0x00, 0x00 }, // ?
|
||||
{ 0x00, 0x1C, 0x26, 0x2A, 0x2A, 0x26, 0x20, 0x1C, 0x00, 0x00 }, // @
|
||||
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x3E, 0x22, 0x22, 0x00, 0x00 }, // A
|
||||
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // B
|
||||
{ 0x00, 0x1C, 0x22, 0x20, 0x20, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // C
|
||||
{ 0x00, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // D
|
||||
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x20, 0x20, 0x3E, 0x00, 0x00 }, // E
|
||||
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x20, 0x20, 0x20, 0x00, 0x00 }, // F
|
||||
{ 0x00, 0x1C, 0x22, 0x20, 0x2E, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // G
|
||||
{ 0x00, 0x22, 0x22, 0x22, 0x3E, 0x22, 0x22, 0x22, 0x00, 0x00 }, // H
|
||||
{ 0x00, 0x3E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // I
|
||||
{ 0x00, 0x02, 0x02, 0x02, 0x02, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // J
|
||||
{ 0x00, 0x22, 0x24, 0x28, 0x30, 0x28, 0x24, 0x22, 0x00, 0x00 }, // K
|
||||
{ 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x3E, 0x00, 0x00 }, // L
|
||||
{ 0x00, 0x22, 0x36, 0x2A, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // M
|
||||
{ 0x00, 0x22, 0x22, 0x32, 0x2A, 0x26, 0x22, 0x22, 0x00, 0x00 }, // N
|
||||
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // O
|
||||
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x20, 0x20, 0x20, 0x00, 0x00 }, // P
|
||||
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x06, 0x00 }, // Q
|
||||
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x22, 0x22, 0x22, 0x00, 0x00 }, // R
|
||||
{ 0x00, 0x1C, 0x22, 0x20, 0x1C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // S
|
||||
{ 0x00, 0x3E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // T
|
||||
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // U
|
||||
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x14, 0x14, 0x08, 0x00, 0x00 }, // V
|
||||
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x2A, 0x36, 0x22, 0x00, 0x00 }, // W
|
||||
{ 0x00, 0x22, 0x22, 0x14, 0x08, 0x14, 0x22, 0x22, 0x00, 0x00 }, // X
|
||||
{ 0x00, 0x22, 0x22, 0x14, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // Y
|
||||
{ 0x00, 0x3E, 0x02, 0x04, 0x08, 0x10, 0x20, 0x3E, 0x00, 0x00 }, // Z
|
||||
{ 0x00, 0x0C, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0C, 0x00, 0x00 }, // [
|
||||
// Custom icon glyph, not a real backslash - a backspace symbol: Caps
|
||||
// Lock's up arrow (see FONT_ICON_CAPSLOCK) rotated 270 degrees to
|
||||
// point left instead, adapted to this font's fixed 6x10 tile (each of
|
||||
// Caps Lock's row widths becomes a column height here, centered
|
||||
// vertically). Backslash was never drawn anyway, and isn't a key this
|
||||
// virtual keyboard can type - see FONT_ICON_BACKSPACE.
|
||||
{ 0x00, 0x00, 0x08, 0x1F, 0x3F, 0x3F, 0x1F, 0x08, 0x00, 0x00 }, // FONT_ICON_BACKSPACE
|
||||
{ 0x00, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x18, 0x00, 0x00 }, // ]
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ^ (not drawn in source font)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3E }, // _
|
||||
// Custom icon glyph, not a real backtick - an up arrow for Shift, same
|
||||
// head as Caps Lock's (see FONT_ICON_CAPSLOCK) but with a shaft 2px
|
||||
// thinner, plus one empty row near the bottom of it, so it reads as a
|
||||
// lighter/broken version of Caps Lock's thicker uninterrupted one.
|
||||
// Backtick was never drawn anyway, and isn't a key this virtual
|
||||
// keyboard can type - see FONT_ICON_SHIFT.
|
||||
{ 0x0C, 0x1E, 0x3F, 0x0C, 0x0C, 0x0C, 0x0C, 0x00, 0x0C, 0x00 }, // FONT_ICON_SHIFT
|
||||
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // a
|
||||
{ 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // b
|
||||
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // c
|
||||
{ 0x00, 0x02, 0x02, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // d
|
||||
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x3E, 0x20, 0x1C, 0x00, 0x00 }, // e
|
||||
{ 0x00, 0x0C, 0x12, 0x10, 0x3C, 0x10, 0x10, 0x10, 0x00, 0x00 }, // f
|
||||
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // g
|
||||
{ 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // h
|
||||
{ 0x00, 0x08, 0x00, 0x18, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // i
|
||||
{ 0x00, 0x02, 0x00, 0x06, 0x02, 0x02, 0x02, 0x02, 0x22, 0x1C }, // j
|
||||
{ 0x00, 0x20, 0x20, 0x22, 0x24, 0x38, 0x24, 0x22, 0x00, 0x00 }, // k
|
||||
{ 0x00, 0x30, 0x10, 0x10, 0x10, 0x10, 0x10, 0x0E, 0x00, 0x00 }, // l
|
||||
{ 0x00, 0x00, 0x00, 0x3C, 0x2A, 0x2A, 0x2A, 0x2A, 0x00, 0x00 }, // m
|
||||
{ 0x00, 0x00, 0x00, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // n
|
||||
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // o
|
||||
{ 0x00, 0x00, 0x00, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x20, 0x20 }, // p
|
||||
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x02 }, // q
|
||||
{ 0x00, 0x00, 0x00, 0x2C, 0x32, 0x20, 0x20, 0x20, 0x00, 0x00 }, // r
|
||||
{ 0x00, 0x00, 0x00, 0x1E, 0x20, 0x1C, 0x02, 0x3C, 0x00, 0x00 }, // s
|
||||
{ 0x00, 0x10, 0x10, 0x3C, 0x10, 0x10, 0x10, 0x0E, 0x00, 0x00 }, // t
|
||||
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // u
|
||||
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x14, 0x08, 0x00, 0x00 }, // v
|
||||
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x2A, 0x2A, 0x14, 0x00, 0x00 }, // w
|
||||
{ 0x00, 0x00, 0x00, 0x22, 0x14, 0x08, 0x14, 0x22, 0x00, 0x00 }, // x
|
||||
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // y
|
||||
{ 0x00, 0x00, 0x00, 0x3E, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // z
|
||||
{ 0x00, 0x04, 0x08, 0x08, 0x10, 0x08, 0x08, 0x04, 0x00, 0x00 }, // {
|
||||
// Custom icon glyph, not a real pipe - a "return" arrow for the
|
||||
// keyboard's newline key: a vertical riser down the right side that
|
||||
// hooks left into a leftward-pointing arrowhead, i.e. "<-|" rotated
|
||||
// into an L. Pipe was never drawn anyway, and isn't a key this
|
||||
// virtual keyboard can type - see FONT_ICON_NEWLINE.
|
||||
{ 0x02, 0x02, 0x02, 0x02, 0x0E, 0x1E, 0x08, 0x00, 0x00, 0x00 }, // FONT_ICON_NEWLINE
|
||||
{ 0x00, 0x10, 0x08, 0x08, 0x04, 0x08, 0x08, 0x10, 0x00, 0x00 }, // }
|
||||
// Custom icon glyph, not a real tilde - a spacebar symbol for the
|
||||
// keyboard's space key: an underscore with a tick at each end, like
|
||||
// "|___|". Tilde was never drawn anyway, and was explicitly dropped
|
||||
// from this virtual keyboard's own key set - see FONT_ICON_SPACE.
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x3F }, // FONT_ICON_SPACE
|
||||
// Custom icon glyph, not a real ASCII character - a thick, solid,
|
||||
// uninterrupted up arrow for Caps Lock (contrast FONT_ICON_SHIFT's
|
||||
// same head but a thinner, gapped shaft). Assigned to char code 127
|
||||
// (DEL) since that codepoint is never legitimately typed text and
|
||||
// (unlike 128+) is still a positive value regardless of whether this
|
||||
// platform's plain `char` is signed - see FONT_ICON_CAPSLOCK.
|
||||
{ 0x0C, 0x1E, 0x3F, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x00 }, // FONT_ICON_CAPSLOCK
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile
|
||||
};
|
||||
|
||||
errorret_t fontDefaultInit(void) {
|
||||
const int32_t width = (int32_t)mathNextPowTwo(
|
||||
FONT_DEFAULT_COLUMNS * FONT_DEFAULT_TILE_WIDTH
|
||||
);
|
||||
const int32_t height = (int32_t)mathNextPowTwo(
|
||||
FONT_DEFAULT_ROWS * FONT_DEFAULT_TILE_HEIGHT
|
||||
);
|
||||
|
||||
color_t *pixels = memoryAllocate(sizeof(color_t) * width * height);
|
||||
memoryZero(pixels, sizeof(color_t) * width * height);
|
||||
|
||||
for(uint16_t i = 0; i < FONT_DEFAULT_TILE_COUNT; i++) {
|
||||
const uint16_t tileX = (i % FONT_DEFAULT_COLUMNS) * FONT_DEFAULT_TILE_WIDTH;
|
||||
const uint16_t tileY = (i / FONT_DEFAULT_COLUMNS) * FONT_DEFAULT_TILE_HEIGHT;
|
||||
|
||||
for(uint8_t row = 0; row < FONT_DEFAULT_TILE_HEIGHT; row++) {
|
||||
const uint8_t bits = FONT_DEFAULT_GLYPHS[i][row];
|
||||
|
||||
for(uint8_t col = 0; col < FONT_DEFAULT_TILE_WIDTH; col++) {
|
||||
if(!((bits >> (FONT_DEFAULT_TILE_WIDTH - 1 - col)) & 1)) continue;
|
||||
pixels[((tileY + row) * width) + (tileX + col)] = COLOR_WHITE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FONT_DEFAULT_TILESET.tileWidth = FONT_DEFAULT_TILE_WIDTH;
|
||||
FONT_DEFAULT_TILESET.tileHeight = FONT_DEFAULT_TILE_HEIGHT;
|
||||
FONT_DEFAULT_TILESET.columns = FONT_DEFAULT_COLUMNS;
|
||||
FONT_DEFAULT_TILESET.rows = FONT_DEFAULT_ROWS;
|
||||
FONT_DEFAULT_TILESET.tileCount = FONT_DEFAULT_TILE_COUNT;
|
||||
FONT_DEFAULT_TILESET.uv[0] = (float_t)FONT_DEFAULT_TILE_WIDTH / (float_t)width;
|
||||
FONT_DEFAULT_TILESET.uv[1] = (float_t)FONT_DEFAULT_TILE_HEIGHT / (float_t)height;
|
||||
|
||||
const texturedata_t data = { .rgbaColors = pixels };
|
||||
errorret_t textureResult = textureInit(
|
||||
&FONT_DEFAULT_TEXTURE, width, height, TEXTURE_FORMAT_RGBA, data
|
||||
);
|
||||
memoryFree(pixels);
|
||||
errorChain(textureResult);
|
||||
|
||||
FONT_DEFAULT.texture = &FONT_DEFAULT_TEXTURE;
|
||||
FONT_DEFAULT.tileset = &FONT_DEFAULT_TILESET;
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t fontDefaultDispose(void) {
|
||||
errorChain(textureDispose(&FONT_DEFAULT_TEXTURE));
|
||||
FONT_DEFAULT.texture = NULL;
|
||||
FONT_DEFAULT.tileset = NULL;
|
||||
errorOk();
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
#include "display/texture/texture.h"
|
||||
#include "display/texture/tileset.h"
|
||||
|
||||
@@ -13,3 +14,86 @@ typedef struct {
|
||||
texture_t *texture;
|
||||
tileset_t *tileset;
|
||||
} font_t;
|
||||
|
||||
/**
|
||||
* Pixel width/height of a single default-font glyph tile.
|
||||
*/
|
||||
#define FONT_DEFAULT_TILE_WIDTH 6
|
||||
#define FONT_DEFAULT_TILE_HEIGHT 10
|
||||
|
||||
/** Grid layout of the generated default-font texture, in tiles. */
|
||||
#define FONT_DEFAULT_COLUMNS 16
|
||||
#define FONT_DEFAULT_ROWS 6
|
||||
|
||||
/**
|
||||
* Number of glyphs defined in FONT_DEFAULT_GLYPHS (FONT_DEFAULT_COLUMNS *
|
||||
* FONT_DEFAULT_ROWS), covering the printable ASCII range starting at
|
||||
* TEXT_CHAR_START ('!'), one custom icon glyph in a trailing unused tile
|
||||
* (see FONT_ICON_CAPSLOCK), plus one more genuinely unused trailing tile.
|
||||
* FONT_ICON_SHIFT/FONT_ICON_NEWLINE reuse existing-but-blank slots within
|
||||
* the printable range instead of more trailing tiles - see their own
|
||||
* comments.
|
||||
*/
|
||||
#define FONT_DEFAULT_TILE_COUNT (FONT_DEFAULT_COLUMNS * FONT_DEFAULT_ROWS)
|
||||
|
||||
/**
|
||||
* Custom (non-ASCII-meaning) icon glyphs baked into FONT_DEFAULT_GLYPHS
|
||||
* at otherwise-unused codepoints within the range this font covers -
|
||||
* safe to use anywhere a char_t string is expected, e.g. a uibutton_t
|
||||
* label. Kept below 128: char_t is a plain `char`, whose signedness
|
||||
* varies by platform, so a codepoint of 128 or above isn't safely
|
||||
* representable everywhere this engine targets.
|
||||
*/
|
||||
// Thick, solid, uninterrupted up arrow - Caps Lock. A trailing tile
|
||||
// (char code 127/DEL) that was never a real character to begin with.
|
||||
#define FONT_ICON_CAPSLOCK "\x7F"
|
||||
// Same up arrow, but with a thinner and gapped shaft - Shift. Reuses
|
||||
// the backtick's glyph slot: backtick was never drawn by this font
|
||||
// anyway, and isn't a key this engine's virtual keyboard can type.
|
||||
#define FONT_ICON_SHIFT "`"
|
||||
// A "return" arrow (down then left, with a leftward arrowhead) - the
|
||||
// keyboard's newline key. Reuses the pipe's glyph slot, for the same
|
||||
// reason as FONT_ICON_SHIFT.
|
||||
#define FONT_ICON_NEWLINE "|"
|
||||
// An underscore with a tick at each end ("|___|") - the keyboard's
|
||||
// space key. Reuses the tilde's glyph slot: tilde was never drawn by
|
||||
// this font, and was explicitly dropped from this engine's virtual
|
||||
// keyboard's own key set.
|
||||
#define FONT_ICON_SPACE "~"
|
||||
// Caps Lock's arrow rotated to point left - the keyboard's backspace
|
||||
// key. Reuses the backslash's glyph slot, for the same reason as
|
||||
// FONT_ICON_SHIFT/FONT_ICON_NEWLINE/FONT_ICON_SPACE. Last free reused
|
||||
// slot below 128 - one more custom icon after this needs
|
||||
// FONT_DEFAULT_COLUMNS/ROWS grown to make room.
|
||||
#define FONT_ICON_BACKSPACE "\\"
|
||||
|
||||
extern font_t FONT_DEFAULT;
|
||||
|
||||
/**
|
||||
* Hard coded bitmap data for the built-in default font. Indexed
|
||||
* [glyph][row], where glyph 0 corresponds to TEXT_CHAR_START ('!') and
|
||||
* glyphs run consecutively through the printable ASCII range. Each row
|
||||
* byte holds FONT_DEFAULT_TILE_WIDTH bit flags, one per pixel column:
|
||||
* bit (FONT_DEFAULT_TILE_WIDTH - 1) is the leftmost pixel and bit 0 is
|
||||
* the rightmost; 1 means the pixel is set, 0 means it is not.
|
||||
*/
|
||||
extern const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
|
||||
FONT_DEFAULT_TILE_HEIGHT
|
||||
];
|
||||
|
||||
/**
|
||||
* Builds the default font's texture + tileset directly from
|
||||
* FONT_DEFAULT_GLYPHS, without going through the asset system - so the
|
||||
* engine always has a usable font to render with regardless of whether
|
||||
* asset loading (e.g. the packed .dsk archive) succeeds.
|
||||
*
|
||||
* @return Either an error or success result.
|
||||
*/
|
||||
errorret_t fontDefaultInit(void);
|
||||
|
||||
/**
|
||||
* Disposes of the default font created by fontDefaultInit().
|
||||
*
|
||||
* @return Either an error or success result.
|
||||
*/
|
||||
errorret_t fontDefaultDispose(void);
|
||||
|
||||
+114
-41
@@ -9,34 +9,15 @@
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
#include "display/spritebatch/spritebatch.h"
|
||||
#include "asset/asset.h"
|
||||
#include "asset/loader/display/assettextureloader.h"
|
||||
#include "asset/loader/display/assettilesetloader.h"
|
||||
#include "display/shader/shaderunlit.h"
|
||||
|
||||
font_t FONT_DEFAULT;
|
||||
|
||||
errorret_t textInit(void) {
|
||||
assetloaderinput_t input = { .texture = TEXTURE_FORMAT_RGBA };
|
||||
assetentry_t *entryTexture = assetLock(
|
||||
"ui/minogram.png", ASSET_LOADER_TYPE_TEXTURE, &input
|
||||
);
|
||||
assetentry_t *entryTileset = assetLock(
|
||||
"ui/minogram.dtf", ASSET_LOADER_TYPE_TILESET, NULL
|
||||
);
|
||||
errorChain(assetRequireLoaded(entryTexture));
|
||||
errorChain(assetRequireLoaded(entryTileset));
|
||||
|
||||
FONT_DEFAULT.texture = &entryTexture->data.texture;
|
||||
FONT_DEFAULT.tileset = &entryTileset->data.tileset;
|
||||
errorChain(fontDefaultInit());
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t textDispose(void) {
|
||||
FONT_DEFAULT.texture = NULL;
|
||||
FONT_DEFAULT.tileset = NULL;
|
||||
assetUnlock("ui/minogram.png");
|
||||
assetUnlock("ui/minogram.dtf");
|
||||
errorChain(fontDefaultDispose());
|
||||
errorOk();
|
||||
}
|
||||
|
||||
@@ -73,6 +54,62 @@ spritebatchsprite_t textGetSprite(
|
||||
return sprite;
|
||||
}
|
||||
|
||||
int32_t textBuffer(
|
||||
const float_t x,
|
||||
const float_t y,
|
||||
const char_t *text,
|
||||
font_t *font,
|
||||
spritebatchsprite_t *outSprites,
|
||||
const int32_t maxSprites,
|
||||
int32_t *charIndex,
|
||||
float_t *posX,
|
||||
float_t *posY
|
||||
) {
|
||||
assertNotNull(text, "Text cannot be NULL");
|
||||
|
||||
if(outSprites == NULL) {
|
||||
int32_t count = 0;
|
||||
char_t c;
|
||||
int32_t i = 0;
|
||||
while((c = text[i++]) != '\0') {
|
||||
if(c != ' ' && c != '\n') count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
assertNotNull(font, "Font cannot be NULL");
|
||||
assertTrue(maxSprites > 0, "Max sprites must be greater than zero");
|
||||
assertNotNull(posX, "Output posX cannot be NULL");
|
||||
assertNotNull(posY, "Output posY cannot be NULL");
|
||||
assertNotNull(charIndex, "Output charIndex cannot be NULL");
|
||||
|
||||
int32_t spriteIndex = 0;
|
||||
char_t c;
|
||||
for(;;) {
|
||||
c = text[*charIndex];
|
||||
if(c == '\0') break;
|
||||
(*charIndex)++;
|
||||
|
||||
if(c == '\n') {
|
||||
*posX = x;
|
||||
*posY += font->tileset->tileHeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(c == ' ') {
|
||||
*posX += font->tileset->tileWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
outSprites[spriteIndex++] = textGetSprite((vec2){*posX, *posY}, c, font);
|
||||
*posX += font->tileset->tileWidth;
|
||||
|
||||
if(spriteIndex >= maxSprites) break;
|
||||
}
|
||||
|
||||
return spriteIndex;
|
||||
}
|
||||
|
||||
errorret_t textDraw(
|
||||
const float_t x,
|
||||
const float_t y,
|
||||
@@ -81,10 +118,11 @@ errorret_t textDraw(
|
||||
font_t *font
|
||||
) {
|
||||
assertNotNull(text, "Text cannot be NULL");
|
||||
int32_t length = strlen(text);
|
||||
if(length == 0) errorOk();
|
||||
|
||||
if(font == NULL) font = &FONT_DEFAULT;
|
||||
|
||||
spritebatchsprite_t sprite;
|
||||
shadermaterial_t material = {
|
||||
.unlit = {
|
||||
.color = color,
|
||||
@@ -92,31 +130,25 @@ errorret_t textDraw(
|
||||
}
|
||||
};
|
||||
|
||||
spritebatchsprite_t sprites[32];
|
||||
float_t posX = x;
|
||||
float_t posY = y;
|
||||
int32_t buffered = 0;
|
||||
int32_t charIndex = 0;
|
||||
do {
|
||||
buffered = textBuffer(
|
||||
x, y, text, font,
|
||||
sprites,
|
||||
sizeof(sprites) / sizeof(spritebatchsprite_t),
|
||||
&charIndex, &posX, &posY
|
||||
);
|
||||
errorChain(spriteBatchBuffer(sprites, buffered, &SHADER_UNLIT, material));
|
||||
} while(charIndex < length);
|
||||
|
||||
char_t c;
|
||||
int32_t i = 0;
|
||||
while((c = text[i++]) != '\0') {
|
||||
if(c == '\n') {
|
||||
posX = x;
|
||||
posY += font->tileset->tileHeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(c == ' ') {
|
||||
posX += font->tileset->tileWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
sprite = textGetSprite((vec2){posX, posY}, c, font);
|
||||
errorChain(spriteBatchBuffer(&sprite, 1, &SHADER_UNLIT, material));
|
||||
posX += font->tileset->tileWidth;
|
||||
}
|
||||
errorOk();
|
||||
}
|
||||
|
||||
void textMeasure(
|
||||
int32_t textMeasure(
|
||||
const char_t *text,
|
||||
const font_t *font,
|
||||
int32_t *outWidth,
|
||||
@@ -129,6 +161,7 @@ void textMeasure(
|
||||
int32_t width = 0;
|
||||
int32_t height = font->tileset->tileHeight;
|
||||
int32_t lineWidth = 0;
|
||||
int32_t spriteCount = 0;
|
||||
|
||||
char_t c;
|
||||
int32_t i = 0;
|
||||
@@ -141,10 +174,50 @@ void textMeasure(
|
||||
}
|
||||
|
||||
lineWidth += font->tileset->tileWidth;
|
||||
|
||||
if(c != ' ') {
|
||||
spriteCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if(lineWidth > width) width = lineWidth;
|
||||
|
||||
*outWidth = width;
|
||||
*outHeight = height;
|
||||
|
||||
return spriteCount;
|
||||
}
|
||||
|
||||
void textWrap(char_t *text, const font_t *font, const float_t maxWidth) {
|
||||
assertNotNull(text, "Text cannot be NULL");
|
||||
assertNotNull(font, "Font cannot be NULL");
|
||||
|
||||
float_t fontWidth = (float_t)font->tileset->tileWidth;
|
||||
if(fontWidth <= 0.0f) return;
|
||||
|
||||
int32_t charsPerLine = (int32_t)(maxWidth / fontWidth);
|
||||
if(charsPerLine <= 0) return;
|
||||
|
||||
int32_t lineWidth = 0;
|
||||
int32_t lastSpace = -1;
|
||||
|
||||
for(int32_t i = 0; text[i] != '\0'; i++) {
|
||||
if(text[i] == '\n') {
|
||||
lineWidth = 0;
|
||||
lastSpace = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(text[i] == ' ') {
|
||||
lastSpace = i;
|
||||
}
|
||||
|
||||
lineWidth++;
|
||||
|
||||
if(lineWidth > charsPerLine && lastSpace != -1) {
|
||||
text[lastSpace] = '\n';
|
||||
lineWidth = i - lastSpace;
|
||||
lastSpace = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
#define TEXT_CHAR_START '!'
|
||||
|
||||
extern font_t FONT_DEFAULT;
|
||||
|
||||
/**
|
||||
* Initializes the text system.
|
||||
*
|
||||
@@ -42,6 +40,37 @@ spritebatchsprite_t textGetSprite(
|
||||
const font_t *font
|
||||
);
|
||||
|
||||
/**
|
||||
* Buffers a string into sprites for rendering. If outSprites is NULL then
|
||||
* the function will only return the count of sprites necessary for the buffer.
|
||||
*
|
||||
* posX and posY are updated whilst buffering characters, if you need to do
|
||||
* buffering in sets then these will be reusable between buffer commands. Start
|
||||
* by setting these to x and y initially.
|
||||
*
|
||||
* @param x The x-coordinate to start buffering the text at.
|
||||
* @param y The y-coordinate to start buffering the text at.
|
||||
* @param text The null-terminated string of text to buffer.
|
||||
* @param font Font to use for rendering.
|
||||
* @param outSprites Pointer to an array of spritebatchsprite_t.
|
||||
* @param maxSprites The maximum number of sprites in outSprites.
|
||||
* @param charIndex Pointer to an int32_t to store the character indexed.
|
||||
* @param posX Pointer to a float_t to store the final x position.
|
||||
* @param posY Pointer to a float_t to store the final y position.
|
||||
* @return The count of sprites buffered.
|
||||
*/
|
||||
int32_t textBuffer(
|
||||
const float_t x,
|
||||
const float_t y,
|
||||
const char_t *text,
|
||||
font_t *font,
|
||||
spritebatchsprite_t *outSprites,
|
||||
const int32_t maxSprites,
|
||||
int32_t *charIndex,
|
||||
float_t *posX,
|
||||
float_t *posY
|
||||
);
|
||||
|
||||
/**
|
||||
* Draws a string of text at the specified position.
|
||||
*
|
||||
@@ -67,10 +96,25 @@ errorret_t textDraw(
|
||||
* @param font Font to use for measurement.
|
||||
* @param outWidth Pointer to store the measured width in pixels.
|
||||
* @param outHeight Pointer to store the measured height in pixels.
|
||||
* @return The count of sprites that will be rendered for the given text.
|
||||
*/
|
||||
void textMeasure(
|
||||
int32_t textMeasure(
|
||||
const char_t *text,
|
||||
const font_t *font,
|
||||
int32_t *outWidth,
|
||||
int32_t *outHeight
|
||||
);
|
||||
|
||||
/**
|
||||
* Word-wraps text in place for display at up to maxWidth pixels wide,
|
||||
* by replacing the space nearest each overflow point with a newline.
|
||||
* Length is unchanged - this only ever swaps existing spaces for
|
||||
* newlines, never inserts characters - so it's always safe to call on a
|
||||
* fixed-size buffer. A single word wider than maxWidth on its own is
|
||||
* left unbroken.
|
||||
*
|
||||
* @param text Null-terminated, caller-owned buffer to wrap in place.
|
||||
* @param font Font to measure character width with.
|
||||
* @param maxWidth Maximum line width, in pixels.
|
||||
*/
|
||||
void textWrap(char_t *text, const font_t *font, const float_t maxWidth);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "display/display.h"
|
||||
|
||||
texture_t TEXTURE_WHITE;
|
||||
color_t TEXTURE_WHITE_PIXELS[4*4] = {
|
||||
color_t TEXTURE_WHITE_PIXELS[TEXTURE_FIXED_WIDTH * TEXTURE_FIXED_HEIGHT] = {
|
||||
COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE,
|
||||
COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE,
|
||||
COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE,
|
||||
@@ -20,7 +20,7 @@ color_t TEXTURE_WHITE_PIXELS[4*4] = {
|
||||
};
|
||||
|
||||
texture_t TEXTURE_TEST;
|
||||
color_t TEXTURE_TEST_PIXELS[4*4] = {
|
||||
color_t TEXTURE_TEST_PIXELS[TEXTURE_FIXED_WIDTH * TEXTURE_FIXED_HEIGHT] = {
|
||||
COLOR_BLACK, COLOR_MAGENTA, COLOR_BLACK, COLOR_MAGENTA,
|
||||
COLOR_MAGENTA, COLOR_BLACK, COLOR_MAGENTA, COLOR_BLACK,
|
||||
COLOR_BLACK, COLOR_MAGENTA, COLOR_BLACK, COLOR_MAGENTA,
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
#error "textureDisposePlatform should not be defined."
|
||||
#endif
|
||||
|
||||
#define TEXTURE_FIXED_WIDTH 4
|
||||
#define TEXTURE_FIXED_HEIGHT 4
|
||||
|
||||
typedef textureformatplatform_t textureformat_t;
|
||||
typedef textureplatform_t texture_t;
|
||||
|
||||
@@ -29,9 +32,9 @@ typedef union texturedata_u {
|
||||
} texturedata_t;
|
||||
|
||||
extern texture_t TEXTURE_WHITE;
|
||||
extern color_t TEXTURE_WHITE_PIXELS[4*4];
|
||||
extern color_t TEXTURE_WHITE_PIXELS[TEXTURE_FIXED_WIDTH * TEXTURE_FIXED_HEIGHT];
|
||||
extern texture_t TEXTURE_TEST;
|
||||
extern color_t TEXTURE_TEST_PIXELS[4*4];
|
||||
extern color_t TEXTURE_TEST_PIXELS[TEXTURE_FIXED_WIDTH * TEXTURE_FIXED_HEIGHT];
|
||||
|
||||
/**
|
||||
* Initializes a texture.
|
||||
|
||||
@@ -16,13 +16,24 @@
|
||||
#include "asset/asset.h"
|
||||
#include "ui/ui.h"
|
||||
#include "assert/assert.h"
|
||||
#ifdef DUSK_NETWORK
|
||||
#include "network/network.h"
|
||||
#endif
|
||||
#include "system/system.h"
|
||||
#include "console/console.h"
|
||||
#include "save/save.h"
|
||||
#include "audio/audio.h"
|
||||
|
||||
engine_t ENGINE;
|
||||
|
||||
void engineTestToneOnEnd(audiostream_t *stream) {
|
||||
consolePrint("Test tone finished playing");
|
||||
}
|
||||
|
||||
void engineTestToneOnLoop(audiostream_t *stream) {
|
||||
consolePrint("Test tone looped");
|
||||
}
|
||||
|
||||
errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
||||
assertInit();
|
||||
memoryZero(&ENGINE, sizeof(engine_t));
|
||||
@@ -37,14 +48,36 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
||||
errorChain(systemInit());
|
||||
errorChain(inputInit());
|
||||
errorChain(assetInit());
|
||||
// errorChain(saveInit());
|
||||
errorChain(saveInit());
|
||||
errorChain(localeManagerInit());
|
||||
errorChain(displayInit());
|
||||
errorChain(audioInit());
|
||||
errorChain(uiInit());
|
||||
errorChain(rpgInit());
|
||||
#ifdef DUSK_NETWORK
|
||||
errorChain(networkInit());
|
||||
#endif
|
||||
errorChain(sceneInit());
|
||||
|
||||
// Smoke-tests the audio subsystem end to end (asset loading -> PCM
|
||||
// streaming -> platform playback) against a real WAV asset rather than
|
||||
// synthesizing PCM data at runtime.
|
||||
assetentry_t *testToneEntry = assetLock(
|
||||
"audio/pepsiman.wav", ASSET_LOADER_TYPE_WAV, NULL
|
||||
// "audio/audiotest.wav", ASSET_LOADER_TYPE_WAV, NULL
|
||||
);
|
||||
errorChain(assetRequireLoaded(testToneEntry));
|
||||
|
||||
audiostream_t *stream = audioAquireStream(testToneEntry);
|
||||
assertNotNull(stream, "No free audio stream slots available.");
|
||||
errorChain(audioStreamInit(stream, testToneEntry));
|
||||
assetUnlockEntry(testToneEntry); // The stream now holds its own lock.
|
||||
|
||||
stream->onEnd = engineTestToneOnEnd;
|
||||
stream->onLoop = engineTestToneOnLoop;
|
||||
audioStreamSetLooping(stream, true);
|
||||
audioStreamPlay(stream);
|
||||
|
||||
consolePrint("Engine initialized");
|
||||
|
||||
#ifdef DUSK_ASSERTIONS_FAKED
|
||||
@@ -53,18 +86,21 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
||||
consolePrint("Assertions real");
|
||||
#endif
|
||||
|
||||
sceneSet(SCENE_TYPE_OVERWORLD);
|
||||
|
||||
sceneSet(SCENE_TYPE_INITIAL);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t engineUpdate(void) {
|
||||
// Order here is important.
|
||||
#ifdef DUSK_NETWORK
|
||||
errorChain(networkUpdate());
|
||||
#endif
|
||||
errorChain(saveUpdate());
|
||||
timeUpdate();
|
||||
inputUpdate();
|
||||
consoleUpdate();
|
||||
errorChain(audioUpdate());
|
||||
errorChain(rpgUpdate());
|
||||
errorChain(sceneUpdate());
|
||||
errorChain(assetUpdate());
|
||||
@@ -82,13 +118,16 @@ void engineExit(void) {
|
||||
|
||||
errorret_t engineDispose(void) {
|
||||
errorChain(sceneDispose());
|
||||
#ifdef DUSK_NETWORK
|
||||
errorChain(networkDispose());
|
||||
#endif
|
||||
errorChain(rpgDispose());
|
||||
localeManagerDispose();
|
||||
errorChain(uiDispose());
|
||||
errorChain(audioDispose());
|
||||
consoleDispose();
|
||||
errorChain(displayDispose());
|
||||
// errorChain(saveDispose());
|
||||
errorChain(saveDispose());
|
||||
errorChain(assetDispose());
|
||||
|
||||
errorOk();
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "event.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
|
||||
void eventInit(
|
||||
event_t *event,
|
||||
eventcallback_t *callbacks,
|
||||
void **users,
|
||||
size_t size
|
||||
) {
|
||||
assertNotNull(event, "event must not be NULL");
|
||||
assertNotNull((void *)callbacks, "callbacks must not be NULL");
|
||||
assertTrue(size > 0, "size must be greater than 0");
|
||||
|
||||
event->callbacks = callbacks;
|
||||
event->users = users;
|
||||
event->size = size;
|
||||
event->count = 0;
|
||||
memoryZero(callbacks, sizeof(eventcallback_t) * size);
|
||||
if(users) memoryZero(users, sizeof(void *) * size);
|
||||
}
|
||||
|
||||
void eventSubscribe(event_t *event, eventcallback_t callback, void *user) {
|
||||
assertNotNull(event, "event must not be NULL");
|
||||
assertNotNull(callback, "callback must not be NULL");
|
||||
|
||||
// Ensure callback isn't already susbcribed
|
||||
for(uint32_t i = 0; i < event->count; i++) {
|
||||
if(event->callbacks[i] != callback) continue;
|
||||
assertUnreachable("Callback already registered, cannot subscribe twice.");
|
||||
}
|
||||
|
||||
assertTrue(event->count < event->size, "event subscriber capacity exceeded");
|
||||
|
||||
event->callbacks[event->count] = callback;
|
||||
if(user) {
|
||||
assertNotNull(event->users, "Cannot add user pointer.");
|
||||
event->users[event->count] = user;
|
||||
}
|
||||
event->count++;
|
||||
}
|
||||
|
||||
void eventUnsubscribe(event_t *event, eventcallback_t callback) {
|
||||
assertNotNull(event, "event must not be NULL");
|
||||
assertNotNull(callback, "callback must not be NULL");
|
||||
|
||||
for(uint32_t i = 0; i < event->count; i++) {
|
||||
if(event->callbacks[i] != callback) continue;
|
||||
|
||||
uint32_t last = event->count - 1;
|
||||
if(i != last) {
|
||||
event->callbacks[i] = event->callbacks[last];
|
||||
if(event->users) event->users[i] = event->users[last];
|
||||
}
|
||||
event->callbacks[last] = NULL;
|
||||
if(event->users) event->users[last] = NULL;
|
||||
event->count--;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void eventInvoke(const event_t *event, void *params) {
|
||||
assertNotNull(event, "event must not be NULL");
|
||||
|
||||
for(uint32_t i = 0; i < event->count; i++) {
|
||||
void *u = event->users ? event->users[i] : NULL;
|
||||
event->callbacks[i](params, u);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "dusk.h"
|
||||
|
||||
typedef void (*eventcallback_t)(void *params, void *user);
|
||||
|
||||
typedef struct {
|
||||
eventcallback_t *callbacks;
|
||||
void **users;
|
||||
size_t size;
|
||||
uint32_t count;
|
||||
} event_t;
|
||||
|
||||
/**
|
||||
* Initializes an event, binding it to the provided backing arrays and clearing
|
||||
* all subscribers. May also be called to reset an event (re-clears subscribers
|
||||
* without changing the backing arrays or size).
|
||||
*
|
||||
* @param event The event to initialize.
|
||||
* @param callbacks Caller-owned array of at least `size` callback slots.
|
||||
* @param users Array of user pointers, matching each callback, or NULL.
|
||||
* @param size Capacity of both arrays, must match.
|
||||
*/
|
||||
void eventInit(
|
||||
event_t *event,
|
||||
eventcallback_t *callbacks,
|
||||
void **users,
|
||||
size_t size
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribes a callback to an event. The callback is invoked with params and
|
||||
* the provided user pointer each time the event fires. The same (callback,
|
||||
* user) pair may only be subscribed once.
|
||||
*
|
||||
* @param event The event to subscribe to.
|
||||
* @param callback The function to call when the event fires.
|
||||
* @param user Arbitrary pointer forwarded to the callback unchanged.
|
||||
*/
|
||||
void eventSubscribe(event_t *event, eventcallback_t callback, void *user);
|
||||
|
||||
/**
|
||||
* Removes a previously subscribed (callback, user) pair. Does nothing if the
|
||||
* pair is not currently subscribed.
|
||||
*
|
||||
* @param event The event to unsubscribe from.
|
||||
* @param callback The callback that was passed to eventSubscribe.
|
||||
*/
|
||||
void eventUnsubscribe(event_t *event, eventcallback_t callback);
|
||||
|
||||
/**
|
||||
* Invokes all subscribed callbacks, passing params and each subscriber's user
|
||||
* pointer.
|
||||
*
|
||||
* @param event The event to invoke.
|
||||
* @param params Arbitrary pointer forwarded to every callback unchanged.
|
||||
*/
|
||||
void eventInvoke(const event_t *event, void *params);
|
||||
@@ -11,32 +11,16 @@
|
||||
#include "util/string.h"
|
||||
#include "util/math.h"
|
||||
#include "time/time.h"
|
||||
#include "event/event.h"
|
||||
|
||||
input_t INPUT;
|
||||
|
||||
errorret_t inputInit(void) {
|
||||
memoryZero(&INPUT, sizeof(input_t));
|
||||
INPUT.deadzone = INPUT_DEADZONE_DEFAULT;
|
||||
|
||||
for(uint8_t i = 0; i < INPUT_ACTION_COUNT; i++) {
|
||||
INPUT.actions[i].action = (inputaction_t)i;
|
||||
INPUT.actions[i].lastValue = 0.0f;
|
||||
INPUT.actions[i].currentValue = 0.0f;
|
||||
|
||||
eventInit(
|
||||
&INPUT.actions[i].onPressed,
|
||||
INPUT.actions[i].onPressedCallbacks,
|
||||
INPUT.actions[i].onPressedUsers,
|
||||
INPUT_ACTION_CALLBACK_COUNT_MAX
|
||||
);
|
||||
|
||||
eventInit(
|
||||
&INPUT.actions[i].onReleased,
|
||||
INPUT.actions[i].onReleasedCallbacks,
|
||||
INPUT.actions[i].onReleasedUsers,
|
||||
INPUT_ACTION_CALLBACK_COUNT_MAX
|
||||
);
|
||||
}
|
||||
|
||||
#ifdef inputInitPlatform
|
||||
@@ -107,8 +91,6 @@ void inputUpdate(void) {
|
||||
inputactiondata_t *act = &INPUT.actions[i];
|
||||
bool_t isDown = act->currentValue > 0.0f;
|
||||
bool_t wasDown = act->lastValue > 0.0f;
|
||||
if(isDown && !wasDown) eventInvoke(&act->onPressed, act);
|
||||
if(!isDown && wasDown) eventInvoke(&act->onReleased, act);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,9 @@
|
||||
#include "inputbutton.h"
|
||||
#include "inputaction.h"
|
||||
|
||||
#define INPUT_LISTENER_PRESSED_MAX 16
|
||||
#define INPUT_LISTENER_RELEASED_MAX INPUT_LISTENER_PRESSED_MAX
|
||||
#define INPUT_DEADZONE_DEFAULT 0.1f
|
||||
|
||||
typedef struct {
|
||||
inputactiondata_t actions[INPUT_ACTION_COUNT];
|
||||
|
||||
inputplatform_t platform;
|
||||
|
||||
/** User-configured gamepad axis deadzone (0.0f to 1.0f). */
|
||||
float_t deadzone;
|
||||
} input_t;
|
||||
|
||||
extern input_t INPUT;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
#pragma once
|
||||
#include "time/time.h"
|
||||
#include "input/inputactiondefs.h"
|
||||
#include "event/event.h"
|
||||
|
||||
#define INPUT_ACTION_CALLBACK_COUNT_MAX 4
|
||||
|
||||
@@ -21,13 +20,6 @@ typedef struct {
|
||||
float_t lastDynamicValue;
|
||||
float_t currentDynamicValue;
|
||||
#endif
|
||||
|
||||
eventcallback_t onPressedCallbacks[INPUT_ACTION_CALLBACK_COUNT_MAX];
|
||||
void *onPressedUsers[INPUT_ACTION_CALLBACK_COUNT_MAX];
|
||||
event_t onPressed;
|
||||
eventcallback_t onReleasedCallbacks[INPUT_ACTION_CALLBACK_COUNT_MAX];
|
||||
void *onReleasedUsers[INPUT_ACTION_CALLBACK_COUNT_MAX];
|
||||
event_t onReleased;
|
||||
} inputactiondata_t;
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,17 +13,31 @@ typedef struct {
|
||||
const char_t *file;
|
||||
} localeinfo_t;
|
||||
|
||||
static const localeinfo_t LOCALE_EN_US = {
|
||||
static const localeinfo_t LOCALE_INFO_EN_US = {
|
||||
.name = "en-US",
|
||||
.file = "locale/en_US.po",
|
||||
};
|
||||
|
||||
static const localeinfo_t LOCALE_JP_JP = {
|
||||
static const localeinfo_t LOCALE_INFO_JP_JP = {
|
||||
.name = "ja-JP",
|
||||
.file = "locale/jp_JP.po",
|
||||
};
|
||||
|
||||
static const localeinfo_t LOCALE_ES_MX = {
|
||||
static const localeinfo_t LOCALE_INFO_ES_MX = {
|
||||
.name = "es-MX",
|
||||
.file = "locale/es_MX.po",
|
||||
};
|
||||
|
||||
static const localeinfo_t * const LOCALE_INFO_LIST[] = {
|
||||
&LOCALE_INFO_EN_US,
|
||||
&LOCALE_INFO_JP_JP,
|
||||
&LOCALE_INFO_ES_MX
|
||||
};
|
||||
|
||||
#define LOCALE_INFO_LIST_COUNT ( \
|
||||
sizeof(LOCALE_INFO_LIST) / sizeof(LOCALE_INFO_LIST[0]) \
|
||||
)
|
||||
|
||||
#define LOCALE_DEFAULT LOCALE_INFO_EN_US
|
||||
|
||||
// EOF
|
||||
|
||||
@@ -7,13 +7,22 @@
|
||||
|
||||
#include "localemanager.h"
|
||||
#include "util/memory.h"
|
||||
#include "util/string.h"
|
||||
#include "assert/assert.h"
|
||||
#include "ui/ui.h"
|
||||
#include "system/system.h"
|
||||
#include "console/console.h"
|
||||
|
||||
localemanager_t LOCALE;
|
||||
|
||||
errorret_t localeManagerInit() {
|
||||
memoryZero(&LOCALE, sizeof(localemanager_t));
|
||||
errorChain(localeManagerSetLocale(&LOCALE_EN_US));
|
||||
|
||||
// TODO: Set locale based on system locale.
|
||||
const localeinfo_t *locale = systemGetLocale();
|
||||
errorChain(localeManagerSetLocale(locale));
|
||||
consolePrint("Locale set to: %s", locale->name);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
@@ -30,6 +39,9 @@ errorret_t localeManagerSetLocale(const localeinfo_t *locale) {
|
||||
assetEntryLock(LOCALE.entry);
|
||||
errorChain(assetRequireLoaded(LOCALE.entry));
|
||||
|
||||
// TODO : Trigger UI update.
|
||||
errorChain(uiUpdateTranslations());
|
||||
|
||||
errorOk();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
#include "localemanager.h"
|
||||
#include "locale/localemanager.h"
|
||||
#include "locale/localeinfo.h"
|
||||
#include "asset/asset.h"
|
||||
|
||||
|
||||
@@ -10,3 +10,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
battlefighter.c
|
||||
party.c
|
||||
)
|
||||
|
||||
# Subdirs
|
||||
add_subdirectory(testbattle)
|
||||
|
||||
+162
-59
@@ -8,6 +8,7 @@
|
||||
#include "battle.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
#include "rpg/cutscene/cutscenesystem.h"
|
||||
|
||||
battle_t BATTLE;
|
||||
|
||||
@@ -51,10 +52,8 @@ void battleStart(
|
||||
BATTLE.fleeAvailable = fleeAvailable;
|
||||
BATTLE.result = BATTLE_RESULT_NONE;
|
||||
BATTLE.round = 1;
|
||||
BATTLE.turnIndex = 0;
|
||||
battleBuildTurnOrder(true);
|
||||
|
||||
BATTLE.active = true;
|
||||
battleSetState(BATTLE_STATE_OPENING);
|
||||
}
|
||||
|
||||
void battleDispose(void) {
|
||||
@@ -62,9 +61,9 @@ void battleDispose(void) {
|
||||
}
|
||||
|
||||
battlefighter_t *battleGetCurrentFighter(void) {
|
||||
if(!BATTLE.active) return NULL;
|
||||
if(BATTLE.turnIndex >= BATTLE.turnCount) return NULL;
|
||||
return &BATTLE.fighters[BATTLE.turnOrder[BATTLE.turnIndex]];
|
||||
if(BATTLE.state != BATTLE_STATE_PLAYER_SELECTION) return NULL;
|
||||
if(BATTLE.selectionIndex >= BATTLE.executionCount) return NULL;
|
||||
return &BATTLE.fighters[BATTLE.executionOrder[BATTLE.selectionIndex]];
|
||||
}
|
||||
|
||||
uint8_t battleGetAliveCount(const battlefighterteam_t team) {
|
||||
@@ -92,91 +91,105 @@ void battleResolveAttack(
|
||||
if(defender->health == 0) defender->status = BATTLE_FIGHTER_STATUS_DEAD;
|
||||
}
|
||||
|
||||
void battleNextTurn(void) {
|
||||
BATTLE.turnIndex++;
|
||||
if(BATTLE.turnIndex < BATTLE.turnCount) return;
|
||||
|
||||
BATTLE.round++;
|
||||
BATTLE.turnIndex = 0;
|
||||
battleBuildTurnOrder(false);
|
||||
}
|
||||
|
||||
battleresult_t battleCheckResult(void) {
|
||||
if(BATTLE.result != BATTLE_RESULT_NONE) return BATTLE.result;
|
||||
|
||||
if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ALLY) == 0) {
|
||||
BATTLE.result = BATTLE_RESULT_LOSS;
|
||||
battleSetResult(BATTLE_RESULT_LOSS);
|
||||
} else if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ENEMY) == 0) {
|
||||
BATTLE.result = BATTLE_RESULT_WIN;
|
||||
battleSetResult(BATTLE_RESULT_WIN);
|
||||
}
|
||||
|
||||
return BATTLE.result;
|
||||
}
|
||||
|
||||
void battleQueueAction(
|
||||
const uint8_t fighterIndex,
|
||||
const battleactiontype_t type,
|
||||
const uint8_t targetIndex
|
||||
) {
|
||||
battleaction_t *action = &BATTLE.actions[fighterIndex];
|
||||
action->type = type;
|
||||
action->targetIndex = targetIndex;
|
||||
|
||||
if(BATTLE.onActionDecided != NULL) {
|
||||
BATTLE.onActionDecided(&BATTLE.fighters[fighterIndex], action);
|
||||
}
|
||||
}
|
||||
|
||||
void battlePlayerAttack(const uint8_t targetIndex) {
|
||||
battlefighter_t *attacker = battleGetCurrentFighter();
|
||||
if(attacker == NULL) return;
|
||||
if(attacker->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) return;
|
||||
battlefighter_t *fighter = battleGetCurrentFighter();
|
||||
if(fighter == NULL) return;
|
||||
if(targetIndex >= BATTLE_FIGHTER_COUNT_MAX) return;
|
||||
if(!battleFighterIsAlive(&BATTLE.fighters[targetIndex])) return;
|
||||
|
||||
battlefighter_t *defender = &BATTLE.fighters[targetIndex];
|
||||
if(!battleFighterIsAlive(defender)) return;
|
||||
|
||||
battleResolveAttack(attacker, defender);
|
||||
battleCheckResult();
|
||||
if(BATTLE.result == BATTLE_RESULT_NONE) battleNextTurn();
|
||||
battleQueueAction(fighter->id, BATTLE_ACTION_ATTACK, targetIndex);
|
||||
BATTLE.selectionIndex++;
|
||||
battleAdvanceSelection();
|
||||
}
|
||||
|
||||
void battlePlayerFlee(void) {
|
||||
battlefighter_t *fighter = battleGetCurrentFighter();
|
||||
if(fighter == NULL) return;
|
||||
if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) return;
|
||||
if(!BATTLE.fleeAvailable) return;
|
||||
|
||||
BATTLE.result = BATTLE_RESULT_FLED;
|
||||
battleSetResult(BATTLE_RESULT_FLED);
|
||||
}
|
||||
|
||||
void battleUpdate(void) {
|
||||
if(!BATTLE.active) return;
|
||||
if(BATTLE.result != BATTLE_RESULT_NONE) return;
|
||||
if(BATTLE.state == BATTLE_STATE_NONE) return;
|
||||
if(BATTLE.state == BATTLE_STATE_ENDED) return;
|
||||
if(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE) return;
|
||||
|
||||
battlefighter_t *current = battleGetCurrentFighter();
|
||||
if(current == NULL) return;
|
||||
switch(BATTLE.state) {
|
||||
case BATTLE_STATE_OPENING:
|
||||
battleSetState(BATTLE_STATE_PRE_ROUND);
|
||||
break;
|
||||
|
||||
if(!battleFighterIsAlive(current)) {
|
||||
battleNextTurn();
|
||||
return;
|
||||
case BATTLE_STATE_PRE_ROUND:
|
||||
battleUpdatePreRound();
|
||||
break;
|
||||
|
||||
case BATTLE_STATE_AI_SELECTION:
|
||||
battleUpdateAiSelection();
|
||||
break;
|
||||
|
||||
case BATTLE_STATE_MOVES_EXECUTING:
|
||||
battleUpdateMovesExecuting();
|
||||
break;
|
||||
|
||||
case BATTLE_STATE_POST_ROUND:
|
||||
battleUpdatePostRound();
|
||||
break;
|
||||
|
||||
default:
|
||||
// BATTLE_STATE_PLAYER_SELECTION: waits on battlePlayerAttack/Flee.
|
||||
// BATTLE_STATE_NONE/ENDED: handled above.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(current->controller != BATTLE_FIGHTER_CONTROLLER_AI) return;
|
||||
|
||||
battlefighter_t *target = battleAIChooseTarget(current);
|
||||
if(target != NULL) battleResolveAttack(current, target);
|
||||
|
||||
battleCheckResult();
|
||||
if(BATTLE.result == BATTLE_RESULT_NONE) battleNextTurn();
|
||||
}
|
||||
|
||||
void battleBuildTurnOrder(const bool_t applyEncounterBias) {
|
||||
BATTLE.turnCount = 0;
|
||||
void battleBuildExecutionOrder(const bool_t applyEncounterBias) {
|
||||
BATTLE.executionCount = 0;
|
||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
||||
if(!battleFighterIsAlive(&BATTLE.fighters[i])) continue;
|
||||
BATTLE.turnOrder[BATTLE.turnCount++] = i;
|
||||
BATTLE.executionOrder[BATTLE.executionCount++] = i;
|
||||
}
|
||||
|
||||
// Insertion sort by speed descending -- fine for BATTLE_FIGHTER_COUNT_MAX.
|
||||
for(uint8_t i = 1; i < BATTLE.turnCount; i++) {
|
||||
const uint8_t key = BATTLE.turnOrder[i];
|
||||
for(uint8_t i = 1; i < BATTLE.executionCount; i++) {
|
||||
const uint8_t key = BATTLE.executionOrder[i];
|
||||
const uint16_t keySpeed = BATTLE.fighters[key].stats.speed;
|
||||
|
||||
int8_t j = (int8_t)i - 1;
|
||||
while(
|
||||
j >= 0 && BATTLE.fighters[BATTLE.turnOrder[j]].stats.speed < keySpeed
|
||||
j >= 0 &&
|
||||
BATTLE.fighters[BATTLE.executionOrder[j]].stats.speed < keySpeed
|
||||
) {
|
||||
BATTLE.turnOrder[j + 1] = BATTLE.turnOrder[j];
|
||||
BATTLE.executionOrder[j + 1] = BATTLE.executionOrder[j];
|
||||
j--;
|
||||
}
|
||||
BATTLE.turnOrder[j + 1] = key;
|
||||
BATTLE.executionOrder[j + 1] = key;
|
||||
}
|
||||
|
||||
if(!applyEncounterBias) return;
|
||||
@@ -192,16 +205,16 @@ void battleMoveTeamFirst(const battlefighterteam_t team) {
|
||||
uint8_t sorted[BATTLE_FIGHTER_COUNT_MAX];
|
||||
uint8_t count = 0;
|
||||
|
||||
for(uint8_t i = 0; i < BATTLE.turnCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.turnOrder[i]].team != team) continue;
|
||||
sorted[count++] = BATTLE.turnOrder[i];
|
||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.executionOrder[i]].team != team) continue;
|
||||
sorted[count++] = BATTLE.executionOrder[i];
|
||||
}
|
||||
for(uint8_t i = 0; i < BATTLE.turnCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.turnOrder[i]].team == team) continue;
|
||||
sorted[count++] = BATTLE.turnOrder[i];
|
||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
||||
if(BATTLE.fighters[BATTLE.executionOrder[i]].team == team) continue;
|
||||
sorted[count++] = BATTLE.executionOrder[i];
|
||||
}
|
||||
|
||||
memoryCopy(BATTLE.turnOrder, sorted, sizeof(uint8_t) * BATTLE.turnCount);
|
||||
memoryCopy(BATTLE.executionOrder, sorted, sizeof(uint8_t) * BATTLE.executionCount);
|
||||
}
|
||||
|
||||
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) {
|
||||
@@ -221,3 +234,93 @@ battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) {
|
||||
|
||||
return weakest;
|
||||
}
|
||||
|
||||
void battleSetState(const battlestate_t next) {
|
||||
const battlestate_t previous = BATTLE.state;
|
||||
BATTLE.state = next;
|
||||
if(BATTLE.onStateChanged != NULL) BATTLE.onStateChanged(previous, next);
|
||||
}
|
||||
|
||||
void battleSetResult(const battleresult_t result) {
|
||||
BATTLE.result = result;
|
||||
battleSetState(BATTLE_STATE_ENDED);
|
||||
}
|
||||
|
||||
bool_t battleFighterNeedsDecision(
|
||||
const uint8_t fighterIndex,
|
||||
const battlefightercontroller_t controller
|
||||
) {
|
||||
return battleFighterIsAlive(&BATTLE.fighters[fighterIndex])
|
||||
&& BATTLE.fighters[fighterIndex].controller == controller
|
||||
&& BATTLE.actions[fighterIndex].type == BATTLE_ACTION_NONE;
|
||||
}
|
||||
|
||||
void battleAdvanceSelection(void) {
|
||||
while(BATTLE.selectionIndex < BATTLE.executionCount) {
|
||||
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.selectionIndex];
|
||||
if(
|
||||
battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_PLAYER)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
BATTLE.selectionIndex++;
|
||||
}
|
||||
|
||||
battleSetState(BATTLE_STATE_AI_SELECTION);
|
||||
}
|
||||
|
||||
void battleUpdatePreRound(void) {
|
||||
// No need to clear BATTLE.actions here: every living fighter's action is
|
||||
// unconditionally reset to BATTLE_ACTION_NONE as it's processed in
|
||||
// battleUpdateMovesExecuting, and round 1 starts pre-zeroed by
|
||||
// battleInit(). Clearing it here would also wipe out any action a
|
||||
// cutscene force-queued while parked at BATTLE_STATE_PRE_ROUND.
|
||||
battleBuildExecutionOrder(BATTLE.round == 1);
|
||||
|
||||
BATTLE.selectionIndex = 0;
|
||||
battleSetState(BATTLE_STATE_PLAYER_SELECTION);
|
||||
battleAdvanceSelection();
|
||||
}
|
||||
|
||||
void battleUpdateAiSelection(void) {
|
||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
||||
const uint8_t fighterIndex = BATTLE.executionOrder[i];
|
||||
if(
|
||||
!battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_AI)
|
||||
) continue;
|
||||
|
||||
battlefighter_t *target =
|
||||
battleAIChooseTarget(&BATTLE.fighters[fighterIndex]);
|
||||
if(target == NULL) continue;
|
||||
|
||||
battleQueueAction(fighterIndex, BATTLE_ACTION_ATTACK, target->id);
|
||||
}
|
||||
|
||||
BATTLE.executionIndex = 0;
|
||||
battleSetState(BATTLE_STATE_MOVES_EXECUTING);
|
||||
}
|
||||
|
||||
void battleUpdateMovesExecuting(void) {
|
||||
if(BATTLE.executionIndex >= BATTLE.executionCount) {
|
||||
battleSetState(BATTLE_STATE_POST_ROUND);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.executionIndex++];
|
||||
battlefighter_t *fighter = &BATTLE.fighters[fighterIndex];
|
||||
if(!battleFighterIsAlive(fighter)) return;
|
||||
|
||||
battleaction_t *action = &BATTLE.actions[fighterIndex];
|
||||
if(action->type == BATTLE_ACTION_ATTACK) {
|
||||
battlefighter_t *target = &BATTLE.fighters[action->targetIndex];
|
||||
if(battleFighterIsAlive(target)) battleResolveAttack(fighter, target);
|
||||
}
|
||||
action->type = BATTLE_ACTION_NONE;
|
||||
|
||||
battleCheckResult();
|
||||
}
|
||||
|
||||
void battleUpdatePostRound(void) {
|
||||
BATTLE.round++;
|
||||
battleSetState(BATTLE_STATE_PRE_ROUND);
|
||||
}
|
||||
|
||||
+155
-36
@@ -27,19 +27,68 @@ typedef enum {
|
||||
BATTLE_RESULT_COUNT
|
||||
} battleresult_t;
|
||||
|
||||
// Where BATTLE currently is within a round. A cutscene can pause progression
|
||||
// (CUTSCENE_PAUSE_BATTLE) and use CUTSCENE_BATTLE_WAIT_STATE to synchronize
|
||||
// with any of these, or CUTSCENE_BATTLE_FORCE_ACTION to decide a fighter's
|
||||
// action ahead of PLAYER_SELECTION/AI_SELECTION reaching them.
|
||||
typedef enum {
|
||||
BATTLE_STATE_NONE, // Battle inactive.
|
||||
|
||||
BATTLE_STATE_OPENING, // Entered once by battleStart().
|
||||
BATTLE_STATE_PRE_ROUND, // Rebuilds execution order, clears the action queue.
|
||||
BATTLE_STATE_PLAYER_SELECTION, // Waits on battlePlayerAttack/Flee.
|
||||
BATTLE_STATE_AI_SELECTION, // Auto-queues every undecided AI fighter.
|
||||
BATTLE_STATE_MOVES_EXECUTING, // Resolves one queued action per update.
|
||||
BATTLE_STATE_POST_ROUND, // Round wrap-up; loops back to PRE_ROUND.
|
||||
|
||||
BATTLE_STATE_ENDED, // Terminal for WIN/LOSS/FLED alike -- see BATTLE.result.
|
||||
|
||||
BATTLE_STATE_COUNT
|
||||
} battlestate_t;
|
||||
|
||||
typedef enum {
|
||||
BATTLE_ACTION_NONE, // No action decided yet for this fighter this round.
|
||||
BATTLE_ACTION_ATTACK,
|
||||
|
||||
BATTLE_ACTION_COUNT
|
||||
} battleactiontype_t;
|
||||
|
||||
typedef struct {
|
||||
bool_t active;
|
||||
battleactiontype_t type;
|
||||
uint8_t targetIndex; // Meaningful for BATTLE_ACTION_ATTACK.
|
||||
} battleaction_t;
|
||||
|
||||
typedef void (*battlestatechangedcallback_t)(
|
||||
const battlestate_t previous,
|
||||
const battlestate_t next
|
||||
);
|
||||
|
||||
typedef void (*battleactiondecidedcallback_t)(
|
||||
const battlefighter_t *fighter,
|
||||
const battleaction_t *action
|
||||
);
|
||||
|
||||
typedef struct {
|
||||
battlestate_t state;
|
||||
battlefighter_t fighters[BATTLE_FIGHTER_COUNT_MAX];
|
||||
battleaction_t actions[BATTLE_FIGHTER_COUNT_MAX];
|
||||
|
||||
battleencountertype_t encounterType;
|
||||
bool_t fleeAvailable;
|
||||
battleresult_t result;
|
||||
|
||||
// Fighter indices (into fighters[]), sorted for the current round.
|
||||
uint8_t turnOrder[BATTLE_FIGHTER_COUNT_MAX];
|
||||
uint8_t turnCount;
|
||||
uint8_t turnIndex;
|
||||
uint8_t executionOrder[BATTLE_FIGHTER_COUNT_MAX];
|
||||
uint8_t executionCount;
|
||||
uint8_t executionIndex;
|
||||
uint16_t round;
|
||||
|
||||
// Cursor into executionOrder used by BATTLE_STATE_PLAYER_SELECTION to find
|
||||
// the next player-controlled fighter that still needs a decision.
|
||||
uint8_t selectionIndex;
|
||||
|
||||
battlestatechangedcallback_t onStateChanged;
|
||||
battleactiondecidedcallback_t onActionDecided;
|
||||
} battle_t;
|
||||
|
||||
extern battle_t BATTLE;
|
||||
@@ -77,11 +126,10 @@ battlefighter_t *battleAddFighter(
|
||||
);
|
||||
|
||||
/**
|
||||
* Starts the battle: builds the opening turn order (biased by
|
||||
* encounterType for the first round only) and marks the battle active.
|
||||
* Call once every fighter has been added via battleAddFighter.
|
||||
* Starts the battle: enters BATTLE_STATE_OPENING and marks the battle
|
||||
* active. Call once every fighter has been added via battleAddFighter.
|
||||
*
|
||||
* @param encounterType Determines the opening round's turn order.
|
||||
* @param encounterType Determines the opening round's execution order.
|
||||
* @param fleeAvailable Whether the party may attempt to flee this battle.
|
||||
*/
|
||||
void battleStart(
|
||||
@@ -95,10 +143,11 @@ void battleStart(
|
||||
void battleDispose(void);
|
||||
|
||||
/**
|
||||
* Returns the fighter whose turn it currently is.
|
||||
* Returns the fighter currently awaiting a player decision.
|
||||
*
|
||||
* @return Pointer to the active fighter, or NULL if the battle isn't
|
||||
* active or has no living fighters left to act.
|
||||
* @return Pointer to the fighter awaiting a decision, or NULL if the battle
|
||||
* isn't in BATTLE_STATE_PLAYER_SELECTION or every player-controlled
|
||||
* fighter has already decided.
|
||||
*/
|
||||
battlefighter_t *battleGetCurrentFighter(void);
|
||||
|
||||
@@ -125,61 +174,68 @@ void battleResolveAttack(
|
||||
);
|
||||
|
||||
/**
|
||||
* Ends the current fighter's turn and advances to the next fighter in
|
||||
* the turn order, starting a new round (rebuilding turn order purely by
|
||||
* speed) once every fighter in the current round has acted.
|
||||
*/
|
||||
void battleNextTurn(void);
|
||||
|
||||
/**
|
||||
* Checks whether the battle has been won or lost, updating and
|
||||
* returning BATTLE.result. Does nothing if a result has already been
|
||||
* set (e.g. by a successful flee).
|
||||
* Checks whether the battle has been won or lost, transitioning to
|
||||
* BATTLE_STATE_ENDED and updating BATTLE.result if so. Does nothing if a
|
||||
* result has already been set (e.g. by a successful flee).
|
||||
*
|
||||
* @return The battle's current result.
|
||||
*/
|
||||
battleresult_t battleCheckResult(void);
|
||||
|
||||
/**
|
||||
* Submits the current fighter's attack against a target, if it is
|
||||
* currently a player-controlled fighter's turn. Resolves the attack,
|
||||
* checks for a battle result, and advances the turn.
|
||||
* Queues an action for a fighter to perform once BATTLE_STATE_MOVES_EXECUTING
|
||||
* reaches them this round, overwriting any action already queued for that
|
||||
* fighter. Fires BATTLE.onActionDecided.
|
||||
*
|
||||
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
|
||||
* @param type The type of action to perform.
|
||||
* @param targetIndex Index into BATTLE.fighters of the target, meaningful
|
||||
* for BATTLE_ACTION_ATTACK.
|
||||
*/
|
||||
void battleQueueAction(
|
||||
const uint8_t fighterIndex,
|
||||
const battleactiontype_t type,
|
||||
const uint8_t targetIndex
|
||||
);
|
||||
|
||||
/**
|
||||
* Submits the currently-selecting fighter's attack against a target, if the
|
||||
* battle is in BATTLE_STATE_PLAYER_SELECTION and awaiting a decision.
|
||||
* Queues the action and advances the selection cursor.
|
||||
*
|
||||
* @param targetIndex Index into BATTLE.fighters of the target.
|
||||
*/
|
||||
void battlePlayerAttack(const uint8_t targetIndex);
|
||||
|
||||
/**
|
||||
* Submits a flee attempt for the current fighter's turn, if it is
|
||||
* currently a player-controlled fighter's turn and fleeing is
|
||||
* available for this battle. Always succeeds, ending the battle with
|
||||
* BATTLE_RESULT_FLED.
|
||||
* Submits a flee attempt for the currently-selecting fighter, if the battle
|
||||
* is in BATTLE_STATE_PLAYER_SELECTION and fleeing is available for this
|
||||
* battle. Always succeeds, ending the battle with BATTLE_RESULT_FLED.
|
||||
*/
|
||||
void battlePlayerFlee(void);
|
||||
|
||||
/**
|
||||
* Updates the battle simulation for one frame: resolves the current
|
||||
* fighter's turn automatically if AI-controlled, otherwise waits for a
|
||||
* player action via battlePlayerAttack/battlePlayerFlee. No-op if the
|
||||
* battle isn't active or already has a result.
|
||||
* Updates the battle simulation for one frame, dispatching on BATTLE.state.
|
||||
* No-op if the battle isn't active, has already ended, or
|
||||
* CUTSCENE_PAUSE_BATTLE is set.
|
||||
*/
|
||||
void battleUpdate(void);
|
||||
|
||||
/**
|
||||
* Rebuilds BATTLE.turnOrder/turnCount from every currently living
|
||||
* Rebuilds BATTLE.executionOrder/executionCount from every currently living
|
||||
* fighter, sorted by speed descending.
|
||||
*
|
||||
* @param applyEncounterBias If true, reorders the freshly speed-sorted
|
||||
* queue so BATTLE.encounterType's favoured team goes first (used only
|
||||
* for the opening round).
|
||||
*/
|
||||
void battleBuildTurnOrder(const bool_t applyEncounterBias);
|
||||
void battleBuildExecutionOrder(const bool_t applyEncounterBias);
|
||||
|
||||
/**
|
||||
* Stably partitions BATTLE.turnOrder so every fighter on the given team
|
||||
* Stably partitions BATTLE.executionOrder so every fighter on the given team
|
||||
* comes first, preserving each side's relative (speed-sorted) order.
|
||||
*
|
||||
* @param team The team to move to the front of the turn order.
|
||||
* @param team The team to move to the front of the execution order.
|
||||
*/
|
||||
void battleMoveTeamFirst(const battlefighterteam_t team);
|
||||
|
||||
@@ -192,3 +248,66 @@ void battleMoveTeamFirst(const battlefighterteam_t team);
|
||||
* living fighters.
|
||||
*/
|
||||
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter);
|
||||
|
||||
/**
|
||||
* Sets BATTLE.state and fires BATTLE.onStateChanged with the previous and
|
||||
* new state.
|
||||
*
|
||||
* @param next The state to transition to.
|
||||
*/
|
||||
void battleSetState(const battlestate_t next);
|
||||
|
||||
/**
|
||||
* Sets BATTLE.result and transitions to BATTLE_STATE_ENDED.
|
||||
*
|
||||
* @param result The result to end the battle with.
|
||||
*/
|
||||
void battleSetResult(const battleresult_t result);
|
||||
|
||||
/**
|
||||
* Checks whether a fighter is a live, undecided candidate for the given
|
||||
* controller -- i.e. whether PLAYER_SELECTION or AI_SELECTION should still
|
||||
* be deciding an action for it this round.
|
||||
*
|
||||
* @param fighterIndex Index into BATTLE.fighters to check.
|
||||
* @param controller The controller PLAYER_SELECTION/AI_SELECTION is
|
||||
* currently deciding for.
|
||||
* @return True if the fighter is alive, matches controller, and has no
|
||||
* action queued yet.
|
||||
*/
|
||||
bool_t battleFighterNeedsDecision(
|
||||
const uint8_t fighterIndex,
|
||||
const battlefightercontroller_t controller
|
||||
);
|
||||
|
||||
/**
|
||||
* Advances BATTLE.selectionIndex to the next player-controlled fighter that
|
||||
* still needs a decision, or transitions to BATTLE_STATE_AI_SELECTION once
|
||||
* none remain.
|
||||
*/
|
||||
void battleAdvanceSelection(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_PRE_ROUND: rebuilds the execution order and moves on
|
||||
* to BATTLE_STATE_PLAYER_SELECTION, positioning the selection cursor.
|
||||
*/
|
||||
void battleUpdatePreRound(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_AI_SELECTION: queues an attack for every undecided
|
||||
* AI-controlled fighter, then moves on to BATTLE_STATE_MOVES_EXECUTING.
|
||||
*/
|
||||
void battleUpdateAiSelection(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_MOVES_EXECUTING: resolves one queued action from
|
||||
* BATTLE.executionOrder per call, or transitions to BATTLE_STATE_POST_ROUND
|
||||
* once every fighter this round has been processed.
|
||||
*/
|
||||
void battleUpdateMovesExecuting(void);
|
||||
|
||||
/**
|
||||
* Handles BATTLE_STATE_POST_ROUND: advances BATTLE.round and transitions
|
||||
* back to BATTLE_STATE_PRE_ROUND.
|
||||
*/
|
||||
void battleUpdatePostRound(void);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
testbattle.c
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "testbattle.h"
|
||||
#include "rpg/battle/battle.h"
|
||||
#include "scene/scene.h"
|
||||
|
||||
void testBattleStart(void) {
|
||||
battleInit();
|
||||
|
||||
const battlefighterstats_t allyOneStats =
|
||||
{ .attack = 10, .defense = 5, .magic = 0, .speed = 10, .luck = 0 };
|
||||
const battlefighterstats_t allyTwoStats =
|
||||
{ .attack = 8, .defense = 4, .magic = 0, .speed = 8, .luck = 0 };
|
||||
const battlefighterstats_t enemyOneStats =
|
||||
{ .attack = 6, .defense = 3, .magic = 0, .speed = 6, .luck = 0 };
|
||||
const battlefighterstats_t enemyTwoStats =
|
||||
{ .attack = 7, .defense = 3, .magic = 0, .speed = 5, .luck = 0 };
|
||||
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
||||
allyOneStats, 30, 10
|
||||
);
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
||||
allyTwoStats, 25, 10
|
||||
);
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
|
||||
enemyOneStats, 20, 5
|
||||
);
|
||||
battleAddFighter(
|
||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
|
||||
enemyTwoStats, 20, 5
|
||||
);
|
||||
|
||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
||||
sceneSet(SCENE_TYPE_BATTLE);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
|
||||
/**
|
||||
* TEMPORARY test hook: sets up a hardcoded mock battle and switches to the
|
||||
* battle scene, so the battle scene (camera/fighters/HUD) can be seen and
|
||||
* played without a real encounter trigger yet.
|
||||
*/
|
||||
void testBattleStart(void);
|
||||
@@ -53,6 +53,25 @@ typedef struct cutscene_s {
|
||||
#define CUTSCENE_WAIT(WAIT) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_WAIT, .wait = WAIT }
|
||||
|
||||
// A named, otherwise no-op position in the item list that cutsceneGoTo
|
||||
// can jump execution straight to. NAME is matched with stringEquals,
|
||||
// not pointer identity, so it's safe to use separate string literals
|
||||
// with the same contents at the marker and at each call site.
|
||||
#define CUTSCENE_MARKER(NAME) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_MARKER, .marker = { .name = NAME } }
|
||||
|
||||
// Restarts the currently running cutscene from its first item,
|
||||
// preserving whatever interact/interacted entities triggered it.
|
||||
#define CUTSCENE_RESTART() \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_RESTART }
|
||||
|
||||
// Requests a switch to a different SCENE_TYPE via sceneSet, then
|
||||
// immediately continues on to whatever follows this item - the switch
|
||||
// itself doesn't happen until the next sceneUpdate() tick, so it does
|
||||
// not take effect this frame.
|
||||
#define CUTSCENE_SCENE(TYPE) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_SCENE, .sceneChange = { .type = TYPE } }
|
||||
|
||||
#define CUTSCENE_CUTSCENE(CUTSCENE) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_CUTSCENE, \
|
||||
@@ -62,6 +81,124 @@ typedef struct cutscene_s {
|
||||
#define CUTSCENE_CALLBACK(CALLBACK) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_CALLBACK, .callback = CALLBACK }
|
||||
|
||||
#define CUTSCENE_PRINT(TEXT) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_PRINT, .print = { .text = TEXT } }
|
||||
|
||||
// Shows a message-only modal (no option buttons) and immediately
|
||||
// continues on to whatever follows this item - it does not wait for
|
||||
// the dialog to be dismissed. Script the rest of the interaction (e.g.
|
||||
// CUTSCENE_CALLBACK to kick off work, CUTSCENE_WAIT, then
|
||||
// CUTSCENE_MODAL_CLOSE) as later items in the same cutscene.
|
||||
// TITLE and MESSAGE are each displayed as-is unless they match a
|
||||
// locale message ID, in which case the translated string is shown
|
||||
// instead - see uiModalLocalize.
|
||||
#define CUTSCENE_MODAL(TITLE, MESSAGE) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_MODAL, \
|
||||
.modal = { .title = TITLE, .message = MESSAGE } \
|
||||
}
|
||||
|
||||
// Shows a modal with option buttons and immediately continues on, same
|
||||
// as CUTSCENE_MODAL - it does not block waiting for a selection.
|
||||
// Back/cancel input is disabled while it's open (see
|
||||
// uiMenuSetDisableBack), so it must be dismissed by picking one; CALLBACK
|
||||
// then fires with the selected option index once the dialog closes.
|
||||
// Option labels are passed as trailing arguments, e.g.
|
||||
// CUTSCENE_MODAL_OPTIONS(title, message, callback, "Retry", "Cancel") -
|
||||
// their strings are not copied by this item, so they must stay valid
|
||||
// until the modal opens (string literals are fine). Like TITLE and
|
||||
// MESSAGE, each option is translated if it matches a locale message ID.
|
||||
#define CUTSCENE_MODAL_OPTIONS(TITLE, MESSAGE, CALLBACK, ...) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_MODAL, \
|
||||
.modal = { \
|
||||
.title = TITLE, .message = MESSAGE, \
|
||||
.options = (const char_t *[]){ __VA_ARGS__ }, \
|
||||
.optionCount = (uint8_t)( \
|
||||
sizeof((const char_t *[]){ __VA_ARGS__ }) / sizeof(const char_t *) \
|
||||
), \
|
||||
.callback = CALLBACK \
|
||||
} \
|
||||
}
|
||||
|
||||
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly one option and
|
||||
// MARKER1 to jump straight to via cutsceneGoTo once it's selected - no
|
||||
// callback function to write. Does not fall through to whatever follows
|
||||
// this item, so MARKER1 must be scripted elsewhere in the same cutscene.
|
||||
#define CUTSCENE_MODAL_OPTIONS_ONE(TITLE, MESSAGE, OPTION1, MARKER1) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
|
||||
.modalOptionsMarkers = { \
|
||||
.title = TITLE, .message = MESSAGE, \
|
||||
.options = { OPTION1 }, \
|
||||
.markers = { MARKER1 }, \
|
||||
.optionCount = 1 \
|
||||
} \
|
||||
}
|
||||
|
||||
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly two options,
|
||||
// jumping straight to OPTION1_MARKER or OPTION2_MARKER via cutsceneGoTo
|
||||
// once the corresponding option is selected - no callback function to
|
||||
// write. Does not fall through to whatever follows this item, so both
|
||||
// markers must be scripted elsewhere in the same cutscene.
|
||||
#define CUTSCENE_MODAL_OPTIONS_TWO( \
|
||||
TITLE, MESSAGE, OPTION1, OPTION1_MARKER, OPTION2, OPTION2_MARKER \
|
||||
) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
|
||||
.modalOptionsMarkers = { \
|
||||
.title = TITLE, .message = MESSAGE, \
|
||||
.options = { OPTION1, OPTION2 }, \
|
||||
.markers = { OPTION1_MARKER, OPTION2_MARKER }, \
|
||||
.optionCount = 2 \
|
||||
} \
|
||||
}
|
||||
|
||||
// Closes the currently open modal (if any). Useful when a modal was
|
||||
// opened outside of a blocking CUTSCENE_MODAL item (e.g. directly via
|
||||
// uiModalOpen) and this cutscene just needs to dismiss it and continue
|
||||
// on to whatever follows this item in the sequence.
|
||||
#define CUTSCENE_MODAL_CLOSE() \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_MODAL_CLOSE }
|
||||
|
||||
// Opens the on-screen keyboard and blocks the cutscene until it closes,
|
||||
// then caches whatever was typed - see cutsceneSystemGetTextCache to
|
||||
// read it back afterwards. __VA_ARGS__ are uikeyboardopen_t designated
|
||||
// initializers, e.g. CUTSCENE_KEYBOARD(.cancel = true, .maxLength = 8).
|
||||
#define CUTSCENE_KEYBOARD(...) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_KEYBOARD, \
|
||||
.keyboard = { .open = { __VA_ARGS__ } } \
|
||||
}
|
||||
|
||||
// (Re)requests an available save device and jumps straight to
|
||||
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same as a
|
||||
// CUTSCENE_MODAL_OPTIONS callback - it does not fall through to
|
||||
// whatever follows this item, so both markers must be scripted
|
||||
// elsewhere in the same cutscene.
|
||||
#define CUTSCENE_SAVE_DEVICE_CHECK(SUCCESS_MARKER, FAILURE_MARKER) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK, \
|
||||
.saveDeviceCheck = { \
|
||||
.successMarker = SUCCESS_MARKER, \
|
||||
.failureMarker = FAILURE_MARKER \
|
||||
} \
|
||||
}
|
||||
|
||||
// (Re)loads every save slot via saveLoadAllSlots() and jumps straight to
|
||||
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same shape as
|
||||
// CUTSCENE_SAVE_DEVICE_CHECK - it does not fall through to whatever
|
||||
// follows this item, so both markers must be scripted elsewhere in the
|
||||
// same cutscene.
|
||||
#define CUTSCENE_SAVE_LOAD_ALL_SLOTS(SUCCESS_MARKER, FAILURE_MARKER) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS, \
|
||||
.saveLoadAllSlots = { \
|
||||
.successMarker = SUCCESS_MARKER, \
|
||||
.failureMarker = FAILURE_MARKER \
|
||||
} \
|
||||
}
|
||||
|
||||
#define CUTSCENE_ENTITY_WALK_TO(ENTITY_INDEX, X, Y, Z) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO, \
|
||||
@@ -144,6 +281,46 @@ typedef struct cutscene_s {
|
||||
#define CUTSCENE_FADE_FROM_WHITE(DURATION) \
|
||||
CUTSCENE_FADE(COLOR_WHITE, COLOR_TRANSPARENT_WHITE, DURATION, EASING_LINEAR)
|
||||
|
||||
#define CUTSCENE_EMOJI(ENTITY_INDEX, EMOJI_TYPE, DURATION) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_EMOJI, \
|
||||
.emoji = { \
|
||||
.entityIndex = ENTITY_INDEX, \
|
||||
.emojiType = EMOJI_TYPE, \
|
||||
.duration = DURATION \
|
||||
} \
|
||||
}
|
||||
|
||||
// AMOUNT ranges 0 (no shake) to 4 (three tiles): 1 is half a tile, 2 is
|
||||
// a full tile, 3 is two tiles, and 4 is three tiles.
|
||||
#define CUTSCENE_SHAKE(AMOUNT, DURATION) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_SHAKE, \
|
||||
.shake = { .amount = AMOUNT, .duration = DURATION } \
|
||||
}
|
||||
|
||||
// Waits until BATTLE.state reaches STATE. Put this BEFORE
|
||||
// CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_BATTLE), not after -- pausing first
|
||||
// freezes BATTLE.state wherever it already is, so it would never reach
|
||||
// STATE on its own to satisfy the wait. Waiting unpaused, then pausing the
|
||||
// moment it's satisfied, catches the battle right at STATE before it can
|
||||
// advance further.
|
||||
#define CUTSCENE_BATTLE_WAIT_STATE(STATE) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE, \
|
||||
.battleWaitState = { .state = STATE } \
|
||||
}
|
||||
|
||||
// Immediately queues an attack for FIGHTER_INDEX against TARGET_INDEX,
|
||||
// bypassing normal player/AI selection for that fighter this round.
|
||||
#define CUTSCENE_BATTLE_FORCE_ACTION(FIGHTER_INDEX, TARGET_INDEX) \
|
||||
{ \
|
||||
.type = CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION, \
|
||||
.battleForceAction = { \
|
||||
.fighterIndex = FIGHTER_INDEX, .targetIndex = TARGET_INDEX \
|
||||
} \
|
||||
}
|
||||
|
||||
#define CUTSCENE_SET_PAUSE(FLAGS) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = (FLAGS) }
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ typedef uint8_t cutscenepause_t;
|
||||
#define CUTSCENE_PAUSE_NPC ((cutscenepause_t)(1 << 0))
|
||||
#define CUTSCENE_PAUSE_PLAYER ((cutscenepause_t)(1 << 1))
|
||||
#define CUTSCENE_PAUSE_WORLD ((cutscenepause_t)(1 << 2))
|
||||
#define CUTSCENE_PAUSE_BATTLE ((cutscenepause_t)(1 << 3))
|
||||
|
||||
#define CUTSCENE_PAUSE_DEFAULT ((cutscenepause_t)( \
|
||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER \
|
||||
))
|
||||
|
||||
#define CUTSCENE_PAUSE_ALL ((cutscenepause_t)( \
|
||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER | CUTSCENE_PAUSE_WORLD \
|
||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER | CUTSCENE_PAUSE_WORLD | \
|
||||
CUTSCENE_PAUSE_BATTLE \
|
||||
))
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "cutscenesystem.h"
|
||||
#include "rpg/entity/entity.h"
|
||||
#include "util/memory.h"
|
||||
#include "util/string.h"
|
||||
#include "assert/assert.h"
|
||||
|
||||
cutscenesystem_t CUTSCENE_SYSTEM;
|
||||
@@ -16,11 +17,7 @@ void cutsceneSystemInit() {
|
||||
memoryZero(&CUTSCENE_SYSTEM, sizeof(cutscenesystem_t));
|
||||
}
|
||||
|
||||
void cutsceneSystemStartCutscene(const cutscene_t *cutscene) {
|
||||
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
|
||||
}
|
||||
|
||||
void cutsceneSystemStartCutsceneWith(
|
||||
void cutsceneSystemPrepare(
|
||||
const cutscene_t *cutscene,
|
||||
entity_t *interact,
|
||||
entity_t *interacted
|
||||
@@ -38,10 +35,49 @@ void cutsceneSystemStartCutsceneWith(
|
||||
CUTSCENE_SYSTEM.entityLastRef = NULL;
|
||||
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
|
||||
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
|
||||
CUTSCENE_SYSTEM.textCache[0] = '\0';
|
||||
CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0.
|
||||
CUTSCENE_SYSTEM.onComplete = NULL;
|
||||
}
|
||||
|
||||
void cutsceneSystemStartCutscene(const cutscene_t *cutscene) {
|
||||
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
|
||||
}
|
||||
|
||||
void cutsceneSystemStartCutsceneWith(
|
||||
const cutscene_t *cutscene,
|
||||
entity_t *interact,
|
||||
entity_t *interacted
|
||||
) {
|
||||
cutsceneSystemPrepare(cutscene, interact, interacted);
|
||||
cutsceneSystemNext();
|
||||
}
|
||||
|
||||
void cutsceneSystemStartCutsceneAndGoToMarker(
|
||||
const cutscene_t *cutscene,
|
||||
const char_t *marker
|
||||
) {
|
||||
cutsceneSystemPrepare(cutscene, NULL, NULL);
|
||||
cutsceneGoTo(marker);
|
||||
}
|
||||
|
||||
void cutsceneRestart(void) {
|
||||
assertNotNull(
|
||||
CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running"
|
||||
);
|
||||
|
||||
// A restart is the same logical run trying again (e.g. retrying a failed
|
||||
// save-device check), not a fresh unrelated start, so it should not
|
||||
// silently drop a completion callback the caller already armed.
|
||||
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
|
||||
cutsceneSystemStartCutsceneWith(
|
||||
CUTSCENE_SYSTEM.scene,
|
||||
CUTSCENE_SYSTEM.entityInteract,
|
||||
CUTSCENE_SYSTEM.entityInteracted
|
||||
);
|
||||
CUTSCENE_SYSTEM.onComplete = onComplete;
|
||||
}
|
||||
|
||||
void cutsceneSystemUpdate() {
|
||||
if(CUTSCENE_SYSTEM.scene == NULL) return;
|
||||
|
||||
@@ -58,6 +94,13 @@ void cutsceneSystemNext() {
|
||||
if(
|
||||
CUTSCENE_SYSTEM.currentItem >= CUTSCENE_SYSTEM.scene->itemCount
|
||||
) {
|
||||
// Saved and cleared before firing so a callback that immediately
|
||||
// starts another cutscene (or sets its own onComplete) isn't clobbered
|
||||
// by this function's own cleanup running after it - same reentrancy
|
||||
// hazard as uiFocusPop, see src/dusk/ui/focus/uifocus.c.
|
||||
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
|
||||
void *userData = CUTSCENE_SYSTEM.userData;
|
||||
|
||||
CUTSCENE_SYSTEM.scene = NULL;
|
||||
CUTSCENE_SYSTEM.currentItem = 0xFF;
|
||||
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
|
||||
@@ -67,6 +110,10 @@ void cutsceneSystemNext() {
|
||||
CUTSCENE_SYSTEM.entityLastRef = NULL;
|
||||
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
|
||||
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
|
||||
CUTSCENE_SYSTEM.textCache[0] = '\0';
|
||||
CUTSCENE_SYSTEM.onComplete = NULL;
|
||||
|
||||
if(onComplete != NULL) onComplete(userData);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,6 +123,35 @@ void cutsceneSystemNext() {
|
||||
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
|
||||
}
|
||||
|
||||
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete) {
|
||||
assertNotNull(
|
||||
CUTSCENE_SYSTEM.scene,
|
||||
"cutsceneSystemSetOnComplete called with no cutscene running"
|
||||
);
|
||||
CUTSCENE_SYSTEM.onComplete = onComplete;
|
||||
}
|
||||
|
||||
void cutsceneGoTo(const char_t *name) {
|
||||
assertNotNull(
|
||||
CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running"
|
||||
);
|
||||
|
||||
for(uint8_t i = 0; i < CUTSCENE_SYSTEM.scene->itemCount; i++) {
|
||||
const cutsceneitem_t *item = &CUTSCENE_SYSTEM.scene->items[i];
|
||||
if(
|
||||
item->type == CUTSCENE_ITEM_TYPE_MARKER &&
|
||||
stringEquals(item->marker.name, name)
|
||||
) {
|
||||
CUTSCENE_SYSTEM.currentItem = i;
|
||||
memoryZero(&CUTSCENE_SYSTEM.data, sizeof(CUTSCENE_SYSTEM.data));
|
||||
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(false, "cutsceneGoTo: no marker found with that name");
|
||||
}
|
||||
|
||||
const cutsceneitem_t * cutsceneSystemGetCurrentItem() {
|
||||
if(CUTSCENE_SYSTEM.scene == NULL) return NULL;
|
||||
|
||||
@@ -144,6 +220,16 @@ uint8_t cutsceneSystemGetTextMiniId(const uint8_t index) {
|
||||
return index;
|
||||
}
|
||||
|
||||
const char_t * cutsceneSystemGetTextCache(void) {
|
||||
return CUTSCENE_SYSTEM.textCache;
|
||||
}
|
||||
|
||||
void cutsceneSystemSetTextCache(const char_t *text) {
|
||||
stringCopy(
|
||||
CUTSCENE_SYSTEM.textCache, text, CUTSCENE_TEXT_CACHE_MAX - 1
|
||||
);
|
||||
}
|
||||
|
||||
void cutsceneSystemDispose() {
|
||||
CUTSCENE_SYSTEM.scene = NULL;
|
||||
CUTSCENE_SYSTEM.currentItem = 0xFF;
|
||||
@@ -154,4 +240,6 @@ void cutsceneSystemDispose() {
|
||||
CUTSCENE_SYSTEM.entityLastRef = NULL;
|
||||
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
|
||||
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
|
||||
CUTSCENE_SYSTEM.textCache[0] = '\0';
|
||||
CUTSCENE_SYSTEM.onComplete = NULL;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ typedef struct entity_s entity_t;
|
||||
// cutscene_t.dataSize.
|
||||
#define CUTSCENE_SYSTEM_SIZE_MAX 8192
|
||||
|
||||
// Size of CUTSCENE_SYSTEM.textCache - keep >= UI_KEYBOARD_TEXT_MAX (see
|
||||
// ui/dialog/keyboard/uikeyboard.h) so text entered via a
|
||||
// CUTSCENE_ITEM_TYPE_KEYBOARD item is never truncated caching it here.
|
||||
#define CUTSCENE_TEXT_CACHE_MAX 64
|
||||
|
||||
typedef struct {
|
||||
const cutscene_t *scene;
|
||||
uint8_t currentItem;
|
||||
@@ -32,12 +37,21 @@ typedef struct {
|
||||
uint8_t areaLastCreated;
|
||||
uint8_t textMiniLastCreated;
|
||||
|
||||
// Free-form text cache, e.g. holding whatever was last typed via a
|
||||
// CUTSCENE_ITEM_TYPE_KEYBOARD item - see cutsceneSystemGetTextCache/
|
||||
// cutsceneSystemSetTextCache. Not tied to any one item type; any item
|
||||
// may read or write it.
|
||||
char_t textCache[CUTSCENE_TEXT_CACHE_MAX];
|
||||
|
||||
// Data (used by the current item).
|
||||
cutsceneitemdata_t data;
|
||||
|
||||
// Custom user data for the running cutscene, sized per-scene by
|
||||
// cutscene_t.dataSize.
|
||||
uint8_t userData[CUTSCENE_SYSTEM_SIZE_MAX];
|
||||
|
||||
// See cutsceneSystemSetOnComplete.
|
||||
cutscenecallback_t onComplete;
|
||||
} cutscenesystem_t;
|
||||
|
||||
extern cutscenesystem_t CUTSCENE_SYSTEM;
|
||||
@@ -67,6 +81,47 @@ void cutsceneSystemStartCutsceneWith(
|
||||
entity_t *interacted
|
||||
);
|
||||
|
||||
/**
|
||||
* Starts a cutscene with no bound entities, jumping straight to the
|
||||
* CUTSCENE_MARKER item with the given name instead of running from the
|
||||
* first item - as if cutsceneGoTo(marker) had been called immediately
|
||||
* after cutsceneSystemStartCutscene. Asserts if no marker with that
|
||||
* name exists in the cutscene.
|
||||
*
|
||||
* @param cutscene Pointer to the cutscene to start.
|
||||
* @param marker Marker name to jump to, matched with stringEquals.
|
||||
*/
|
||||
void cutsceneSystemStartCutsceneAndGoToMarker(
|
||||
const cutscene_t *cutscene,
|
||||
const char_t *marker
|
||||
);
|
||||
|
||||
/**
|
||||
* Restarts the currently running cutscene from its first item,
|
||||
* preserving whatever interact/interacted entities triggered it.
|
||||
* Asserts if no cutscene is running.
|
||||
*/
|
||||
void cutsceneRestart(void);
|
||||
|
||||
/**
|
||||
* Sets a native callback to fire once when the currently running cutscene
|
||||
* finishes by running off the end of its item list. A fresh
|
||||
* cutsceneSystemStartCutscene* call clears any previously set callback, so
|
||||
* call this again after starting a new cutscene to arm it - but
|
||||
* cutsceneRestart() preserves whatever was armed, since a restart (e.g.
|
||||
* retrying a failed check) is the same logical run trying again, not a new
|
||||
* one. Invoked with CUTSCENE_SYSTEM.userData, same as CUTSCENE_CALLBACK.
|
||||
*
|
||||
* Exists so a runtime-loaded cutscene file (which can't store a native
|
||||
* function pointer) can still hand off to native code once it's done,
|
||||
* without needing a whole name->function registry: the file just ends
|
||||
* normally, and whoever started it supplies what happens next.
|
||||
*
|
||||
* @param onComplete Callback to fire on natural completion. May be NULL
|
||||
* to clear a previously set one.
|
||||
*/
|
||||
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete);
|
||||
|
||||
/**
|
||||
* Resolves a raw entity index (or sentinel) to an entity pointer.
|
||||
* Handles CUTSCENE_ENTITY_INTERACT, CUTSCENE_ENTITY_INTERACTED,
|
||||
@@ -97,11 +152,44 @@ uint8_t cutsceneSystemGetAreaId(const uint8_t areaId);
|
||||
*/
|
||||
uint8_t cutsceneSystemGetTextMiniId(const uint8_t index);
|
||||
|
||||
/**
|
||||
* Returns CUTSCENE_SYSTEM.textCache - whatever was last written there via
|
||||
* cutsceneSystemSetTextCache (e.g. by a CUTSCENE_ITEM_TYPE_KEYBOARD item
|
||||
* once its keyboard closes). Empty ("") if nothing has been cached yet
|
||||
* for the running cutscene.
|
||||
*
|
||||
* @returns The cached text.
|
||||
*/
|
||||
const char_t * cutsceneSystemGetTextCache(void);
|
||||
|
||||
/**
|
||||
* Overwrites CUTSCENE_SYSTEM.textCache with a copy of text. Any item may
|
||||
* call this - it isn't tied to any one item type - so later items can
|
||||
* read back whatever the caller wants to pass along, up to
|
||||
* CUTSCENE_TEXT_CACHE_MAX - 1 characters.
|
||||
*
|
||||
* @param text The text to cache; copied internally, safe to be
|
||||
* transient. Must not exceed CUTSCENE_TEXT_CACHE_MAX - 1 characters.
|
||||
*/
|
||||
void cutsceneSystemSetTextCache(const char_t *text);
|
||||
|
||||
/**
|
||||
* Advance to the next item in the cutscene.
|
||||
*/
|
||||
void cutsceneSystemNext();
|
||||
|
||||
/**
|
||||
* Jumps the running cutscene directly to the CUTSCENE_MARKER item with
|
||||
* the given name and starts it immediately, as if cutsceneSystemNext()
|
||||
* had advanced straight to it. Intended to be called from within
|
||||
* another item's start/update (e.g. a CUTSCENE_CALLBACK) to implement
|
||||
* flow control. Asserts if no cutscene is running or no marker with
|
||||
* that name exists in it.
|
||||
*
|
||||
* @param name Marker name to search for, matched with stringEquals.
|
||||
*/
|
||||
void cutsceneGoTo(const char_t *name);
|
||||
|
||||
/**
|
||||
* Update the cutscene system for one frame.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
cutsceneitem.c
|
||||
cutscenecallback.c
|
||||
cutsceneprint.c
|
||||
)
|
||||
|
||||
add_subdirectory(control)
|
||||
@@ -15,3 +16,4 @@ add_subdirectory(item)
|
||||
add_subdirectory(maparea)
|
||||
add_subdirectory(ui)
|
||||
add_subdirectory(battle)
|
||||
add_subdirectory(save)
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
cutscenestartbattle.c
|
||||
cutscenebattlewaitstate.c
|
||||
cutscenebattleforceaction.c
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
||||
|
||||
void cutsceneBattleForceActionStart(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
) {
|
||||
battleQueueAction(
|
||||
item->battleForceAction.fighterIndex, BATTLE_ACTION_ATTACK,
|
||||
item->battleForceAction.targetIndex
|
||||
);
|
||||
}
|
||||
|
||||
bool_t cutsceneBattleForceActionUpdate(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "rpg/battle/battle.h"
|
||||
|
||||
typedef struct {
|
||||
uint8_t fighterIndex;
|
||||
uint8_t targetIndex;
|
||||
} cutscenebattleforceaction_t;
|
||||
|
||||
typedef struct cutsceneitem_s cutsceneitem_t;
|
||||
typedef union cutsceneitemdata_u cutsceneitemdata_t;
|
||||
|
||||
/**
|
||||
* Starts a battle force-action step: immediately queues an attack for the
|
||||
* given fighter against the given target, bypassing normal player/AI
|
||||
* selection for that fighter this round.
|
||||
*
|
||||
* @param item The cutscene item.
|
||||
* @param data Runtime data storage.
|
||||
*/
|
||||
void cutsceneBattleForceActionStart(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates a battle force-action step (always completes immediately).
|
||||
*
|
||||
* @param item The cutscene item.
|
||||
* @param data Runtime data storage.
|
||||
* @returns true always.
|
||||
*/
|
||||
bool_t cutsceneBattleForceActionUpdate(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
||||
|
||||
bool_t cutsceneBattleWaitStateUpdate(
|
||||
const cutsceneitem_t *item,
|
||||
cutsceneitemdata_t *data
|
||||
) {
|
||||
return BATTLE.state == item->battleWaitState.state;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user