Skip to content

Commit d2ff920

Browse files
committed
Start the episode from the right directory, and fit the window
Three things v0.2.0 got wrong, all of which someone hit within minutes of downloading it. The launcher drew its menu and then could not start anything. Inside a bundle SDL_GetBasePath() returns Contents/Resources, not Contents/MacOS, so the episode programs were looked for among the artwork. The data lookup had been landing in the right place by luck, since the data really is in Resources. Where the programs are is now worked out separately from where the data is, and the two are no longer assumed to be the same place. The window opened at three times 640x400, corrected for aspect: 1920x1440, which is taller than a 1080p screen and puts the title bar out of reach. The scale is now the largest whole multiple that leaves room for the window's own frame, and COSMO_SCALE overrides it. The Windows archive showed four executables where one would do. They cannot be merged -- the episodes differ by preprocessor conditionals that include whole actor implementations, so each is genuinely different code -- but they can be kept out of the way, so the episodes now live in a subdirectory and the launcher looks there first. A smoke test runs the packaged game with no window and no sound and insists on seeing timer interrupts delivered, which only the game's own loop produces. It fails against the v0.2.0 archive and passes against this one. A screenshot of a menu is not evidence that a game starts, which is the whole reason this was missed.
1 parent a194a8f commit d2ff920

8 files changed

Lines changed: 250 additions & 11 deletions

File tree

.github/scripts/package.sh

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,22 @@ macos)
5353
;;
5454

5555
linux)
56-
cp "$build/cosmo" "$build/cosmo1" "$build/cosmo2" "$build/cosmo3" "$root/"
56+
# Only the launcher sits at the top. The episodes are programs in their own
57+
# right, but nobody needs to see four of them to play one.
58+
mkdir -p "$root/episodes"
59+
cp "$build/cosmo" "$root/"
60+
cp "$build/cosmo1" "$build/cosmo2" "$build/cosmo3" "$root/episodes/"
5761
cp "$repo/gamedata/COSMO1.STN" "$repo/gamedata/COSMO1.VOL" "$root/"
58-
chmod +x "$root"/cosmo*
62+
chmod +x "$root/cosmo" "$root/episodes"/cosmo*
5963
docs "$root"
6064
(cd "$staging" && tar czf "$dist/$name.tar.gz" "$name")
6165
;;
6266

6367
windows)
64-
cp "$build/cosmo.exe" "$build/cosmo1.exe" "$build/cosmo2.exe" \
65-
"$build/cosmo3.exe" "$root/"
68+
mkdir -p "$root/episodes"
69+
cp "$build/cosmo.exe" "$root/"
70+
cp "$build/cosmo1.exe" "$build/cosmo2.exe" "$build/cosmo3.exe" \
71+
"$root/episodes/"
6672
cp "$repo/gamedata/COSMO1.STN" "$repo/gamedata/COSMO1.VOL" "$root/"
6773
docs "$root"
6874
(cd "$staging" && zip -qr "$dist/$name.zip" "$name")

.github/scripts/smoke-test.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Start the packaged game and check that it actually runs.
4+
#
5+
# smoke-test.sh <macos|linux> <archive>
6+
#
7+
# v0.2.0 shipped a macOS bundle whose launcher drew its menu and then could not
8+
# start an episode: SDL_GetBasePath() returns Contents/Resources inside a
9+
# bundle, and the episode programs are in Contents/MacOS. Everything looked
10+
# right in a screenshot of the menu, which is exactly why a screenshot of the
11+
# menu is not enough.
12+
#
13+
# So this unpacks what is about to be published, runs it with no window and no
14+
# sound, and insists on evidence that the game itself is running: the timer
15+
# interrupt being delivered is something only the game's own loop produces.
16+
set -euo pipefail
17+
18+
platform="${1:?platform}"
19+
archive="${2:?archive}"
20+
21+
work="$(mktemp -d)"
22+
trap 'rm -rf "$work"' EXIT
23+
24+
case "$archive" in
25+
*.tar.gz) tar xzf "$archive" -C "$work" ;;
26+
*.zip) unzip -q "$archive" -d "$work" ;;
27+
*) echo "unknown archive: $archive" >&2; exit 1 ;;
28+
esac
29+
30+
case "$platform" in
31+
macos) program="$(find "$work" -type f -path '*/Cosmo.app/Contents/MacOS/Cosmo')" ;;
32+
linux) program="$(find "$work" -type f -name cosmo -perm -u+x | head -1)" ;;
33+
*) echo "unknown platform: $platform" >&2; exit 1 ;;
34+
esac
35+
36+
if [ -z "$program" ] || [ ! -x "$program" ]; then
37+
echo "no launcher found in $archive" >&2
38+
exit 1
39+
fi
40+
41+
echo "running $program"
42+
43+
log="$work/run.log"
44+
45+
# Episode 1 by name, so the launcher hands straight over rather than waiting on
46+
# a menu nobody is watching.
47+
(
48+
cd "$(dirname "$program")"
49+
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy COSMO_DEBUG=1 \
50+
"$program" 1 > "$log" 2>&1
51+
) &
52+
pid=$!
53+
54+
sleep 20
55+
kill -9 "$pid" 2>/dev/null || true
56+
wait "$pid" 2>/dev/null || true
57+
58+
echo "--- output"
59+
cat "$log" || true
60+
echo "---"
61+
62+
if grep -q 'cannot start' "$log"; then
63+
echo "the launcher could not start the episode" >&2
64+
exit 1
65+
fi
66+
67+
if grep -q 'No episode data' "$log"; then
68+
echo "the game did not find its data" >&2
69+
exit 1
70+
fi
71+
72+
# "delivered=" counts timer interrupts the game's own loop consumed. A number
73+
# above zero means the 1992 code is running, not merely that a process started.
74+
delivered="$(grep -o 'delivered=[0-9]*' "$log" | tail -1 | cut -d= -f2 || true)"
75+
76+
if [ -z "$delivered" ] || [ "$delivered" -le 0 ]; then
77+
echo "the game never ran: no timer interrupts were delivered" >&2
78+
exit 1
79+
fi
80+
81+
echo "the game ran, and consumed $delivered timer interrupts"

.github/workflows/release.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ jobs:
5353
- name: Package
5454
run: bash .github/scripts/package.sh macos build "${{ github.ref_name }}"
5555

56+
# Unpack what is about to be published and make sure the game actually
57+
# starts from it. A menu that draws is not the same as an episode that
58+
# runs, which v0.2.0 proved the hard way.
59+
- name: Start the packaged game
60+
run: bash .github/scripts/smoke-test.sh macos dist/*
61+
5662
- uses: actions/upload-artifact@v7
5763
with:
5864
name: package-macos
@@ -88,6 +94,12 @@ jobs:
8894
- name: Package
8995
run: bash .github/scripts/package.sh linux build "${{ github.ref_name }}"
9096

97+
# Unpack what is about to be published and make sure the game actually
98+
# starts from it. A menu that draws is not the same as an episode that
99+
# runs, which v0.2.0 proved the hard way.
100+
- name: Start the packaged game
101+
run: bash .github/scripts/smoke-test.sh linux dist/*
102+
91103
- uses: actions/upload-artifact@v7
92104
with:
93105
name: package-linux

PLAYING.txt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,34 @@ is written on the way out, exactly as it was on DOS, where killing the program
5151
lost it the same way.
5252

5353

54+
The window
55+
----------
56+
57+
It opens at the largest whole multiple of the original 640x400 that fits your
58+
screen, so the title bar always stays reachable. To choose the size yourself:
59+
60+
COSMO_SCALE=2 ./cosmo (1 is the original size, 3 is large)
61+
62+
Resizing is fine. The picture is letterboxed rather than stretched, so it never
63+
distorts.
64+
65+
66+
What is in this folder
67+
----------------------
68+
69+
cosmo the launcher, and the only thing you need to run
70+
episodes/ one program per episode
71+
COSMO1.* Episode 1's data
72+
73+
The episodes are separate programs rather than one with a switch. They differ
74+
by preprocessor conditionals that include or exclude whole actor
75+
implementations, so each compiles to genuinely different code -- Apogee shipped
76+
three executables for the same reason. They sit in a subdirectory so there is
77+
one obvious thing to run.
78+
79+
On macOS all of that is inside Cosmo.app, where none of it needs looking at.
80+
81+
5482
Episodes 2 and 3
5583
----------------
5684

include/cosmo/paths.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ const char *paths_data_dir(void);
3737
*/
3838
const char *paths_write_dir(void);
3939

40+
/*
41+
* Where the episode programs sit.
42+
*
43+
* Not the same as where the data is, and not what SDL_GetBasePath() returns
44+
* either: inside a macOS bundle that hands back Contents/Resources, while the
45+
* executables live one directory across in Contents/MacOS.
46+
*/
47+
const char *paths_program_dir(void);
48+
4049
/*
4150
* Copy an episode's two group files into the data directory, given the path of
4251
* either one of them. Used when someone points the launcher at a copy of an

src/launcher.c

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -368,18 +368,37 @@ static int replace_process(const char *program, char *argv[])
368368

369369
static int run_episode(int number)
370370
{
371-
const char *base = SDL_GetBasePath();
371+
/*
372+
* The episode programs are looked for in a subdirectory first and beside
373+
* the launcher second.
374+
*
375+
* They cannot be merged into one executable: the episodes differ by
376+
* preprocessor conditionals that include or exclude whole actor
377+
* implementations, so each is genuinely different code with its own copy of
378+
* the game's globals. What can be done is to keep them out of the way, so
379+
* an unpacked folder shows one program to run rather than four.
380+
*/
381+
static const char *const WHERE[] = {"episodes/", ""};
372382
char program[1024];
373383
char *child_argv[2];
384+
SDL_PathInfo info;
385+
size_t i;
386+
bool found = false;
374387

375-
if (!base) {
376-
fprintf(stderr, "cosmo: cannot locate the episode programs (%s)\n",
377-
SDL_GetError());
378-
return 1;
388+
for (i = 0; i < SDL_arraysize(WHERE) && !found; i++) {
389+
int written = snprintf(program, sizeof program, "%s/%scosmo%d%s",
390+
paths_program_dir(), WHERE[i], number,
391+
EPISODE_PROGRAM_SUFFIX);
392+
if (written < 0 || (size_t)written >= sizeof program) continue;
393+
394+
found = SDL_GetPathInfo(program, &info);
379395
}
380396

381-
snprintf(program, sizeof program, "%scosmo%d%s",
382-
base, number, EPISODE_PROGRAM_SUFFIX);
397+
if (!found) {
398+
fprintf(stderr, "cosmo: episode %d is not installed beside the "
399+
"launcher\n", number);
400+
return 1;
401+
}
383402

384403
/* Give up the window before the process image is replaced. */
385404
video_shutdown();

src/platform/paths.c

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
static char data_dir[MAX_PATH_LEN];
2020
static char write_dir[MAX_PATH_LEN];
21+
static char program_dir[MAX_PATH_LEN];
2122

2223
/* The extensions an episode's two group files carry. */
2324
static const char *const GROUP_EXTENSIONS[] = {"STN", "VOL"};
@@ -67,6 +68,40 @@ static bool group_path(char *dest, size_t size, const char *dir, int episode,
6768
return path_join(dest, size, dir, leaf);
6869
}
6970

71+
/*
72+
* Work out where sibling executables are, from where SDL says the application's
73+
* files are.
74+
*
75+
* In a bundle those are two different places. SDL_GetBasePath() points at
76+
* Contents/Resources, because that is where an application's data belongs, but
77+
* the programs are in Contents/MacOS. Reading the first as though it were the
78+
* second is how the launcher came to look for the episodes among the artwork.
79+
*/
80+
static void resolve_program_dir(const char *base)
81+
{
82+
static const char RESOURCES[] = "/Contents/Resources";
83+
size_t base_length, suffix_length = sizeof RESOURCES - 1;
84+
85+
if (!base || !path_copy(program_dir, sizeof program_dir, base)) {
86+
path_copy(program_dir, sizeof program_dir, ".");
87+
return;
88+
}
89+
90+
base_length = SDL_strlen(program_dir);
91+
while (base_length > 1 && (program_dir[base_length - 1] == '/' ||
92+
program_dir[base_length - 1] == '\\')) {
93+
program_dir[--base_length] = '\0';
94+
}
95+
96+
if (base_length > suffix_length &&
97+
SDL_strcasecmp(program_dir + base_length - suffix_length, RESOURCES) == 0)
98+
{
99+
/* Swap the last component: Contents/Resources -> Contents/MacOS. */
100+
program_dir[base_length - suffix_length] = '\0';
101+
SDL_strlcat(program_dir, "/Contents/MacOS", sizeof program_dir);
102+
}
103+
}
104+
70105
static bool dir_has_any_episode(const char *dir)
71106
{
72107
int episode;
@@ -178,6 +213,8 @@ bool paths_init(void)
178213
const char *prefs;
179214
int i, episode;
180215

216+
resolve_program_dir(SDL_GetBasePath());
217+
181218
/* Used in place when the data sits somewhere that takes the saves too. */
182219
for (i = 0; i < count; i++) {
183220
if (dir_has_any_episode(shipped[i]) && dir_is_writable(shipped[i])) {
@@ -228,6 +265,11 @@ const char *paths_write_dir(void)
228265
return write_dir;
229266
}
230267

268+
const char *paths_program_dir(void)
269+
{
270+
return program_dir;
271+
}
272+
231273
bool paths_import_episode(const char *chosen_file, int episode)
232274
{
233275
char folder[MAX_PATH_LEN];

src/platform/video.c

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,46 @@ bool video_write_png(const char *path, int scale)
108108
return ok;
109109
}
110110

111+
/*
112+
* The largest whole-number scale that leaves the window on the screen.
113+
*
114+
* Three times 640x400, corrected for the aspect the pixels had, is 1920x1440 --
115+
* taller than a 1080p display, which puts the title bar above the top of the
116+
* screen where it cannot be reached. The scale is chosen to fit instead, and
117+
* COSMO_SCALE overrides it for anyone who wants a particular size.
118+
*/
119+
#define WINDOW_FRAME_ALLOWANCE 64
120+
121+
static int fitting_scale(int wanted)
122+
{
123+
const char *requested = SDL_getenv("COSMO_SCALE");
124+
SDL_Rect usable;
125+
SDL_DisplayID display;
126+
int scale;
127+
128+
if (requested) {
129+
int value = SDL_atoi(requested);
130+
if (value >= 1) return value;
131+
}
132+
133+
display = SDL_GetPrimaryDisplay();
134+
if (!display || !SDL_GetDisplayUsableBounds(display, &usable)) return wanted;
135+
136+
for (scale = wanted; scale > 1; scale--) {
137+
int w = EGA_SCREEN_W * scale;
138+
int h = (int)(EGA_SCREEN_H * scale * PIXEL_ASPECT);
139+
140+
/*
141+
* The usable bounds already exclude the taskbar or menu bar; what they
142+
* do not account for is the window's own title bar, which is what ends
143+
* up off the top of the screen when the window is too tall.
144+
*/
145+
if (w <= usable.w && h <= usable.h - WINDOW_FRAME_ALLOWANCE) break;
146+
}
147+
148+
return scale;
149+
}
150+
111151
bool video_init(const char *title, int scale)
112152
{
113153
int w, h;
@@ -119,6 +159,8 @@ bool video_init(const char *title, int scale)
119159
return false;
120160
}
121161

162+
scale = fitting_scale(scale);
163+
122164
w = EGA_SCREEN_W * scale;
123165
h = (int)(EGA_SCREEN_H * scale * PIXEL_ASPECT);
124166

0 commit comments

Comments
 (0)