#!/usr/bin/perl
# explore -- play the reconstructed Explore game on the MBasic interpreter.
# Runs with no arguments using sensible defaults; options override them.
use strict; use warnings;

# SECURITY: the game exposes a full shell escape (the ".."/"m" commands -> the
# `do` builtin) and honors environment variables (MBASIC_LIB adds to @INC), so
# it must run with the invoking user's own privileges.  Refuse to start
# setuid/setgid -- this must happen in a BEGIN, before the MBASIC_LIB `use lib`
# below (a compile-time statement) can act on an attacker's environment.
BEGIN {
    my ($rgid) = split ' ', $(;      # real primary gid
    my ($egid) = split ' ', $);      # effective primary gid
    die "explore must not be installed or run setuid/setgid.\n"
        if $< != $> || $rgid != $egid;
}

# Write the game's output as it is produced.  Perl block-buffers a pipe or a
# file, so a session that was redirected showed nothing at all if it was later
# killed or hung -- the player's whole transcript sat unflushed in the buffer.
$| = 1;

# (The umask for shared play is set further down, once we know whether a
# writable shared directory is actually in use; see _seed_var_dir.)

# Restore default SIGINT handling at exit (quit_off sets it to IGNORE and an
# abnormal path might not reach quit_on).
END { $SIG{INT} = 'DEFAULT'; }

# When installed, MBasic and Explore are on the system @INC via the p5-MBasic
# and p5-Explore packages -- no path munging is needed.  For running from an
# unpacked source tree without installing, set MBASIC_LIB to the lib directory.
use if defined $ENV{MBASIC_LIB}, lib => ($ENV{MBASIC_LIB} // '');

use MBasic::Interp;
use MBasic::Registry;
use Explore::Builtins;
use Fcntl qw(O_WRONLY O_CREAT O_EXCL);

# --- defaults (overridable by options / environment) -----------------------
#   The game's read-only content lives at SHAREDIR, its read-write data at
#   VARDIR.  By default the game's Multics path ">site>explore_dir" is mapped
#   to SHAREDIR, and the writable files (named in explore.rwdir) live under
#   VARDIR.  On a packaged install these default to the FHS locations.
my $sharedir = $ENV{EXPLORE_SHAREDIR} // '/usr/local/share/explore';
my $vardir   = $ENV{EXPLORE_VARDIR}   // '/var/games/explore';
my $basic    = $ENV{EXPLORE_BASIC};                  # derived from share if unset
my $helpers  = $ENV{EXPLORE_HELPERS};                # derived from share if unset
my $root     = $ENV{EXPLORE_ROOT}     // '/';       # fallback anchor for '>'
my @prefix;                                          # extra --prefix M=U pairs

# --- argument split: runner options vs. game control arguments -------------
# Two argument vocabularies share one command line.  This runner's own options
# are double-dash (--share, --var, ...); the game's control arguments are the
# authentic single-dash Multics ones (-brief, -pn PATH, ...), which the BASIC
# reads through cnt and arg$(n).  Keeping the dash counts distinct lets the two
# be interleaved freely.
#
# Every argument not consumed here is collected, IN ORDER, and handed to the
# interpreter as the program's argument vector, so cnt counts only real game
# arguments and arg$(n) indexes only them.  Order and adjacency matter to the
# game: -pathname and -modes each consume the argument that follows them.
#
# "--" ends option processing: everything after it goes to the game verbatim.
# That is the escape hatch for the one ambiguous case, a game argument or
# pathname that itself begins with "--".
#
# Each option below takes a value, and a missing one must be refused here: an
# unchecked "shift @ARGV" at the end of the command line yields undef, which
# surfaced much later as a string of "Use of uninitialized value" warnings from
# the runner and from the path translator, or as a failure against a default
# directory the player had just tried to override.
my @gameargs;
my $value = sub {
    my ($opt) = @_;
    die "explore: option $opt requires an argument (try --help)\n" unless @ARGV;
    return shift @ARGV;
};
while (@ARGV) {
    my $f = shift @ARGV;
    if    ($f eq '--')        { push @gameargs, splice(@ARGV); last; }
    elsif ($f eq '--share')   { $sharedir = $value->($f); }
    elsif ($f eq '--var')     { $vardir   = $value->($f); }
    elsif ($f eq '--basic')   { $basic    = $value->($f); }
    elsif ($f eq '--helpers') { $helpers  = $value->($f); }
    elsif ($f eq '--root')    { $root     = $value->($f); }
    elsif ($f eq '--prefix')  { push @prefix, $value->($f); }  # "MULTICS=/unix"
    elsif ($f eq '--help') {
        print <<"USAGE";
usage: explore [options] [game-arguments...]

Options to this runner (may appear anywhere on the command line):
  --share DIR    read-only game content (default $sharedir)
  --var DIR      read-write data directory (default $vardir)
  --basic FILE   the main BASIC program (default <share>/explore.basic)
  --helpers DIR  BASIC helper .basic files (default <share>)
  --root DIR     anchor for unmapped Multics '>' paths (default /)
  --prefix M=U   map a Multics path prefix M to a Unix path U (repeatable);
                 e.g. --prefix '>site>explore_dir=/usr/local/share/explore'
  --             end of options; everything after goes to the game
Environment: EXPLORE_SHAREDIR, EXPLORE_VARDIR, EXPLORE_BASIC, EXPLORE_HELPERS,
             EXPLORE_ROOT, MBASIC_LIB.

Arguments to the game (the original 1980 Multics control arguments, passed
through to the BASIC untouched).  Three of them take the argument that
follows, so keep each pair together and in order:
  -abbrev PATH, -ab PATH  use PATH as the abbreviation file (".explore_abbrev"
                          is appended if PATH does not already end in it)
  -brief, -bf             suppress the welcome message and the news lines
  -modes STR              set modes from a comma-separated list
  -no_startup, -ns        do not read start_up.explore
  -no_version             print no version line
  -pathname PATH, -pn PATH
                          read the game database from PATH instead of
                          <share>/explore.data
  -table_space, -ts       report table space used while loading
  -version                print the long version line
Use "--" first if a game argument or pathname begins with "--".
USAGE
        exit 0;
    }
    elsif ($f =~ /^--/)       { die "unknown option $f (try --help)\n"; }
    else                      { push @gameargs, $f; }   # -> the game
}

# derive defaults that depend on --share, after parsing
$basic   //= "$sharedir/explore.basic";
$helpers //= $sharedir;

$Explore::Builtins::ROOT = $root;

# default prefix map: the game's Multics base -> the read-only share dir.
Explore::Builtins::add_prefix('>site>explore_dir', $sharedir);
# any user-supplied --prefix M=U pairs (later ones are still longest-first)
for my $spec (@prefix) {
    my ($m, $u) = split /=/, $spec, 2;
    # An empty Multics prefix would match at the front of every Multics-absolute
    # path (index($p, '>') == 0) and silently redirect the lot.
    die "bad --prefix (want MULTICS=/unix): $spec\n"
        unless defined $u && defined $m && length $m;
    Explore::Builtins::add_prefix($m, $u);
}

my $registry = MBasic::Registry->new;
Explore::Builtins::register_all($registry);
$registry->register('set_acl',  sub { });   # commented out on Multics: no-op
$registry->register('exec_com', sub { });

# --- prepare the writable data directory (or fall back to read-only) --------
# On first run we create the writable directory and seed it from the share
# masters so that wins and hours edits persist.  If the directory cannot be
# created or written -- e.g. a normal user on a system where an administrator
# has not set up a shared, group-writable /var/games/explore -- we must run the
# game READ-ONLY, because the packaged explore.rwdir points at that directory
# and the game would otherwise try (and fail) to read its data from there.
#
# To force read-only cleanly we override the rwdir the game reads: we point the
# game's ">site>explore_dir>explore.rwdir" at a temporary file containing the
# single line "none", which the game treats as "read-only, no writable dir".
# In read-only mode the game reads hours.data/winners.data from the share
# directory (where the masters live), so it plays; it simply does not record
# wins or enable multiplayer.
my $writable = _seed_var_dir($sharedir, $vardir);

# Shared play needs the files in the writable data directory to be
# group-writable, so that the next player can update them; a 0002 umask gives
# 0664/0775, and MBasic and the lock code create with mode 0666 so the umask
# decides the group and other bits.  That is deliberate for the shared
# directory -- but it used to be set process-wide, which also loosened the
# files a player creates for THEMSELVES: saved games in the current directory,
# .explore_abbrev and start_up.explore in $HOME.  On a system whose users share
# a default group (staff, on macOS) that made a player's saved games writable
# by every other local user, whatever umask they had chosen.
#
# So widen it only when a writable shared directory is really in play -- which
# is the deployment the administrator opted into by creating the directory.  A
# single player with no shared directory keeps their own umask, and the two
# places that must have group-writable files regardless (the seeding below and
# exp_lock_) set it narrowly around their own creates.
umask 0002 if $writable;

unless ($writable) {
    require File::Temp;
    my ($fh, $tmp) = File::Temp::tempfile(UNLINK => 1);
    print $fh "none\n"; close $fh;
    # map the exact rwdir path the game opens to this temporary "none" file
    Explore::Builtins::add_prefix('>site>explore_dir>explore.rwdir', $tmp);
    warn "explore: $vardir is not writable; playing read-only "
       . "(wins will not be recorded).  See the README (PERMISSIONS) to enable "
       . "writable or multiplayer play.\n";
}

my $interp = MBasic::Interp->new(
    registry    => $registry,
    search_path => [ $helpers ],
    argv        => [ @gameargs ],   # cnt / arg$(n) in the BASIC
);
$interp->load_main($basic);
# Validate every helper up front (load, link, index) so a load-time error in a
# helper -- a parse rejection, a dangling jump, a duplicate sub -- is reported
# now, before play begins, rather than dying deep in a session the first time
# that helper is called (which would lose the player's progress).
eval { $interp->load_all_helpers($helpers); 1 }
    or die "explore: a BASIC helper failed to load:\n$@";
$interp->run(pathxlate => \&Explore::Builtins::mult_path);

# Copy the sample writable files to the var dir if absent; return 1 if the var
# dir is usable (writable), 0 to fall back to read-only.  The read-only masters
# of hours.data/winners.data live in the share dir (they are also read directly
# in read-only mode); here we copy them to the var dir for writable play.
sub _seed_var_dir {
    my ($share, $var) = @_;
    require File::Copy;
    unless (-d $var) {
        eval { require File::Path; File::Path::make_path($var); 1 } or return 0;
    }
    return 0 unless -w $var;
    # Group-writable for shared play, matching the manual PERMISSIONS
    # instructions, whatever umask the player happens to be using.
    my $oldmask = umask 0002;
    for my $name (qw(hours.data winners.data)) {
        my $dst = "$var/$name";
        my $src = "$share/$name";
        next unless -e $src;
        # Create the copy with O_CREAT|O_EXCL|O_NOFOLLOW rather than testing
        # -e first.  The shared directory is group-writable by design, and the
        # old "!-e $dst && -e $src" pair both raced and followed symlinks: -e
        # follows, so a DANGLING symlink planted at hours.data read as absent
        # and the copy -- and the chmod after it -- went straight through to
        # whatever the link named, creating a file outside the directory as
        # the player.  Every other file operation in this game is hardened
        # this way; this one had been missed.  O_EXCL also makes "only if
        # missing" atomic, and the mode now comes from the create (0666 & the
        # 0002 umask above = 0664), so no chmod is needed at all.
        next unless sysopen(my $fh, $dst,
                            O_WRONLY | O_CREAT | O_EXCL
                              | Explore::Builtins::O_NOFOLLOW_(), 0666);
        unless (File::Copy::copy($src, $fh) && close $fh) {
            close $fh; unlink $dst;         # leave no truncated master behind
        }
    }
    umask $oldmask if defined $oldmask;
    return 1;
}

__END__

=head1 NAME

explore - play the reconstructed 1980 Multics game "Explore"

=head1 SYNOPSIS

    explore                          # play with default locations
    explore --share DIR --var DIR    # override the data locations
    explore --prefix '>a>b=/unix/x'  # add a Multics->Unix path mapping
    explore -brief -ts               # pass control arguments to the game
    explore --var DIR -pn ./my.data  # runner options and game arguments mixed
    explore -- -pn --odd-name        # "--" ends options; the rest is the game's

=head1 DESCRIPTION

C<explore> runs the reconstructed 1980 Multics adventure game I<Explore> on the
L<MBasic> interpreter, executing the authentic BASIC source unmodified.  With no
arguments it uses sensible default locations and auto-seeds a writable data
directory so that wins and sorcerer edits persist; if that directory cannot be
written it falls back to read-only single-player.

=head1 OPTIONS

=over 4

=item B<--share> I<DIR>

The read-only game content directory (the BASIC program, the helper C<.basic>
subroutines, and the read-only data).  Default F</usr/local/share/explore>.

=item B<--var> I<DIR>

The read-write data directory (working C<hours.data>, C<winners.data>, and the
multiplayer files).  Default F</var/games/explore>.

=item B<--basic> I<FILE>

The main BASIC program.  Default F<< <share>/explore.basic >>.

=item B<--helpers> I<DIR>

Where the BASIC helper C<.basic> files live.  Default F<< <share> >>.

=item B<--root> I<DIR>

The anchor for any Multics C<< > >> path not covered by a prefix mapping.
Default F</>.

=item B<--prefix> I<MULTICS>=I<UNIX>

Map a Multics path prefix to a Unix path, repeatable.  By default
C<< >site>explore_dir >> maps to the share directory, so the game's unmodified
paths resolve without editing the BASIC.

=item B<-->

End of options.  Every remaining argument is passed to the game untouched,
even if it begins with C<-->.

=back

=head1 GAME ARGUMENTS

The command line carries two separate vocabularies.  The options above belong
to this runner and are double-dash; anything else on the line is a B<game>
argument and is passed through to the BASIC program, which reads the arguments
with the C<cnt> and C<arg$(n)> built-ins.  These are the authentic single-dash
Multics control arguments the game accepted in 1980, so the reconstructed
source needs no modification to honor them.

Because the two vocabularies differ in their dash count, they may be
interleaved freely: runner options are recognized anywhere on the line and are
removed before the rest is handed to the game.  C<cnt> therefore counts only
real game arguments, and C<arg$(n)> indexes only them.

=over 4

=item B<-abbrev> I<PATH> / B<-ab> I<PATH>

Use I<PATH> as the abbreviation file, appending C<.explore_abbrev> if it does
not already end in that.

=item B<-brief> / B<-bf>

Suppress the welcome message and the news lines.

=item B<-modes> I<STR>

Set modes from a comma-separated list.

=item B<-no_startup> / B<-ns>

Do not read C<start_up.explore>.

=item B<-no_version>

Print no version line.

=item B<-pathname> I<PATH> / B<-pn> I<PATH>

Read the game database from I<PATH> instead of F<< <share>/explore.data >>.

=item B<-table_space> / B<-ts>

Report table space used while loading.

=item B<-version>

Print the long version line.

=back

C<-abbrev>, C<-modes> and C<-pathname> each consume the argument that follows
them, so keep those pairs adjacent and in order; the runner preserves the
relative order of everything it passes through.

One case is genuinely ambiguous: a game argument or pathname that itself
begins with C<-->, which the runner would otherwise try to parse as its own
option.  Put C<--> first to resolve it --- everything after C<--> goes to the
game verbatim.

=head1 ENVIRONMENT

C<EXPLORE_SHAREDIR>, C<EXPLORE_VARDIR>, C<EXPLORE_BASIC>, C<EXPLORE_HELPERS>,
C<EXPLORE_ROOT> mirror the options.  C<MBASIC_LIB>, if set, adds a directory to
C<@INC> (for running from an unpacked source tree without installing).

=head1 FILES

The read-only content in the share directory: F<explore.basic>, the helper
F<exp_*.basic> files, F<explore.data>, F<explore.help>, and the sample
F<hours.data> / F<winners.data> masters.  The writable copies live in the var
directory.  See the distribution README for the F<hours.data> format and for
multiplayer setup.

=head1 SEE ALSO

L<Explore::Builtins>, L<MBasic>.

=head1 AUTHOR

Jim Lippard <lippard@discord.org>

=head1 LICENSE

Copyright (c) 2026 Jim Lippard.  Free software under the BSD 3-Clause License.

=cut
