Actually add new test scripts.
diff --git a/regress/NiHTest.pm b/regress/NiHTest.pm
new file mode 100644
index 0000000..295fc64
--- /dev/null
+++ b/regress/NiHTest.pm
@@ -0,0 +1,995 @@
+package NiHTest;
+
+use strict;
+use warnings;
+
+use Cwd;
+use File::Copy;
+use File::Path qw(mkpath);
+use IPC::Open3;
+use Symbol 'gensym';
+use UNIVERSAL;
+
+use Data::Dumper qw(Dumper);
+use Text::Diff;
+
+#  NiHTest -- package to run regression tests
+#  Copyright (C) 2002-2014 Dieter Baron and Thomas Klausner
+#
+#  This file is part of ckmame, a program to check rom sets for MAME.
+#  The authors can be contacted at <ckmame@nih.at>
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions
+#  are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in
+#     the documentation and/or other materials provided with the
+#     distribution.
+#  3. The names of the authors may not be used to endorse or promote
+#     products derived from this software without specific prior
+#     written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS
+#  OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+#  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
+#  DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+#  DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
+#  GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+#  IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+#  OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
+#  IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# runtest TESTNAME
+#
+# files:
+#   TESTNAME.test: test scenario
+#
+# test scenario:
+#    Lines beginning with # are comments.
+#
+#    The following commands are recognized; return and args must
+#    appear exactly once, the others are optional.
+#
+#	args ARGS
+#	    run program with command line arguments ARGS
+#
+#	description TEXT
+#	    description of what test is for
+#
+#	features FEATURE ...
+#	    only run test if all FEATUREs are present, otherwise skip it.
+#
+#	file TEST IN OUT
+#	    copy file IN as TEST, compare against OUT after program run.
+#
+#	file-del TEST IN
+#	    copy file IN as TEST, check that it is removed by program.
+#
+#	file-new TEST OUT
+#	    check that file TEST is created by program and compare
+#	    against OUT.
+#
+#	mkdir MODE NAME
+#	    create directory NAME with permissions MODE.
+#
+#	pipein COMMAND ARGS ...
+#	    pipe output of running COMMAND to program's stdin.
+#
+#	preload LIBRARY
+#	    pre-load LIBRARY before running program.
+#
+#	program PRG
+#	    run PRG instead of ckmame.
+#
+#	return RET
+#	    RET is the expected exit code
+#
+#	setenv VAR VALUE
+#	    set environment variable VAR to VALUE.
+#
+#	stderr TEXT
+#	    program is expected to produce the error message TEXT.  If
+#	    multiple stderr commands are used, the messages are
+#	    expected in the order given.
+#
+#       stderr-replace REGEX REPLACEMENT
+#           run regex replacement over expected and got stderr output.
+#
+#	stdout TEXT
+#	    program is expected to print TEXT to stdout.  If multiple
+#	    stdout commands are used, the messages are expected in
+#	    the order given.
+#
+#	touch MTIME FILE
+#	    set last modified timestamp of FILE to MTIME (seconds since epoch).
+#	    If FILE doesn't exist, an empty file is created.
+#
+#	ulimit C VALUE
+#	    set ulimit -C to VALUE while running the program.
+#
+# exit status
+#	runtest uses the following exit codes:
+#	    0: test passed
+#	    1: test failed
+#	    2: other error
+#	   77: test was skipped
+#
+# environment variables:
+#   RUN_GDB: if set, run gdb on program in test environment
+#   KEEP_BROKEN: if set, don't delete test environment if test failed
+#   NO_CLEANUP: if set, don't delete test environment
+#   SETUP_ONLY: if set, exit after creating test environment
+#   VERBOSE: if set, be more verbose (e. g., output diffs)
+
+my %EXIT_CODES = (
+	PASS => 0,
+	FAIL => 1,
+	SKIP => 77,
+	ERROR => 99
+    );
+
+sub new {
+	my $class = UNIVERSAL::isa ($_[0], __PACKAGE__) ? shift : __PACKAGE__;
+	my $self = bless {}, $class;
+	
+	my ($opts) = @_;
+
+	$self->{default_program} = $opts->{default_program};
+	$self->{zipcmp} = $opts->{zipcmp} // 'zipcmp';
+	$self->{zipcmp_flags} = $opts->{zipcmp_flags};
+
+	$self->{directives} = {
+		args => { type => 'string...', once => 1, required => 1 },
+		description => { type => 'string', once => 1 },
+		features => { type => 'string...', once => 1 },
+		file => { type => 'string string string' },
+		'file-del' => { type => 'string string' },
+		'file-new' => { type => 'string string' },
+		mkdir => { type => 'string string' },
+		pipein => { type => 'string', once => 1 },
+		preload => { type => 'string', once => 1 },
+		program => { type => 'string', once => 1 },
+		'return' => { type => 'int', once => 1, required => 1 },
+		setenv => { type => 'string string' },
+		stderr => { type => 'string' },
+		'stderr-replace' => { type => 'string string' },
+		stdout => { type => 'string' },
+		touch => { type => 'int string' },
+		ulimit => { type => 'char string' }
+	};
+	
+	$self->{compare_by_type} = {};
+	$self->{copy_by_type} = {};
+	$self->{hooks} = {};
+
+	$self->add_comparator('zip/zip', \&comparator_zip);
+	
+	$self->{srcdir} = $opts->{srcdir} // $ENV{srcdir};
+	
+	if (!defined($self->{srcdir}) || $self->{srcdir} eq '') {
+		$self->{srcdir} = `sed -n 's/^srcdir = \(.*\)/\1/p' Makefile`;
+		chomp($self->{srcdir});
+	}
+	
+	$self->{in_sandbox} = 0;
+	
+	$self->{verbose} = $ENV{VERBOSE};
+	$self->{keep_broken} = $ENV{KEEP_BROKEN};
+	$self->{no_cleanup} = $ENV{NO_CLEANUP};
+	$self->{setup_only} = $ENV{SETUP_ONLY};
+
+	return $self;
+}
+
+
+sub add_comparator {
+	my ($self, $ext, $sub) = @_;
+	
+	return $self->add_file_proc('compare_by_type', $ext, $sub);
+}
+
+
+sub add_copier {
+	my ($self, $ext, $sub) = @_;
+
+	return $self->add_file_proc('copy_by_type', $ext, $sub);
+}
+
+
+sub add_directive {
+	my ($self, $name, $def) = @_;
+	
+	if (exists($self->{directives}->{$name})) {
+		$self->die("directive $name already defined");
+	}
+	
+	# TODO: validate $def
+	
+	$self->{directives}->{$name} = $def;
+	
+	return 1;
+}
+
+
+sub add_file_proc {
+	my ($self, $proc, $ext, $sub) = @_;
+
+	$self->{$proc}->{$ext} = [] unless (defined($self->{$proc}->{$ext}));
+	unshift @{$self->{$proc}->{$ext}}, $sub;
+
+	return 1;
+}
+
+
+sub add_hook {
+	my ($self, $hook, $sub) = @_;
+	
+	$self->{hooks}->{$hook} = [] unless (defined($self->{hooks}->{$hook}));
+	push @{$self->{hooks}->{$hook}}, $sub;
+
+	return 1;
+}
+
+
+sub end {
+	my ($self, @results) = @_;
+
+	my $result = 'PASS';
+
+	for my $r (@results) {
+		if ($r eq 'ERROR' || ($r eq 'FAIL' && $result ne 'ERROR')) {
+			$result = $r;
+		}
+	}
+
+	$self->end_test($result);
+}
+
+
+sub run {
+	my ($self, @argv) = @_;
+
+	$self->setup(@argv);
+
+	$self->end($self->runtest());
+}
+
+
+sub runtest {
+	my ($self, $tag) = @_;
+
+	$self->sandbox_create($tag);
+	$self->sandbox_enter();
+	
+	my $ok = 1;
+	$ok &= $self->copy_files();
+	$ok &= $self->run_hook('post_copy_files');
+	$ok &= $self->touch_files();
+	$ok &= $self->run_hook('prepare_sandbox');
+	return 'ERROR' unless ($ok);
+
+	if ($self->{setup_only}) {
+	    $self->sandbox_leave();
+	    return 'SKIP';
+	}
+
+	for my $env (@{$self->{test}->{'setenv'}}) {
+	    $ENV{$env->[0]} = $env->[1];
+	}
+	if (defined($self->{test}->{'preload'})) {
+	    $ENV{LD_PRELOAD} = cwd() . "/../.libs/$self->{test}->{'preload'}";
+	}
+
+	$self->run_program();
+
+	for my $env (@{$self->{test}->{'setenv'}}) {
+	    delete ${ENV{$env->[0]}};
+	}
+	if (defined($self->{test}->{'preload'})) {
+	    delete ${ENV{LD_PRELOAD}};
+	}
+
+	if ($self->{test}->{stdout}) {
+		$self->{expected_stdout} = [ @{$self->{test}->{stdout}} ];
+	}
+	else {
+		$self->{expected_stdout} = [];
+	}
+	if ($self->{test}->{stderr}) {
+		$self->{expected_stderr} = [ @{$self->{test}->{stderr}} ];
+	}
+	else {
+		$self->{expected_stderr} = [];
+	}
+
+	$self->run_hook('post_run_program');
+
+	my @failed = ();
+	
+	if ($self->{exit_status} != $self->{test}->{return} // 0) {
+		push @failed, 'exit status';
+		if ($self->{verbose}) {
+			print "Unexpected exit status:\n";
+			print "-" . ($self->{test}->{return} // 0) . "\n+$self->{exit_status}\n";
+		}
+	}
+	
+	if (!$self->compare_arrays($self->{expected_stdout}, $self->{stdout}, 'output')) {
+		push @failed, 'output';
+	}
+	if (!$self->compare_arrays($self->{expected_stderr}, $self->{stderr}, 'error output')) {
+		push @failed, 'error output';
+	}
+	if (!$self->compare_files()) {
+		push @failed, 'files';
+	}
+	
+	$self->{failed} = \@failed;
+	
+	$self->run_hook('checks');
+	
+	my $result = scalar(@{$self->{failed}}) == 0 ? 'PASS' : 'FAIL';
+
+	$self->sandbox_leave();
+	if (!($self->{no_cleanup} || ($self->{keep_broken} && $result eq 'FAIL'))) {
+		$self->sandbox_remove();
+	}
+
+	$self->print_test_result($tag, $result, join ', ', @{$self->{failed}});
+
+	return $result;
+}
+
+
+sub setup {
+	my ($self, @argv) = @_;
+	
+	if (scalar(@argv) != 1) {
+		print STDERR "Usage: $0 testcase\n";
+		exit(1);
+	}
+	
+	my $testcase = shift @argv;
+
+	$testcase .= '.test' unless ($testcase =~ m/\.test$/);
+
+	my $testcase_file = $self->find_file($testcase);
+	
+	$self->die("cannot find test case $testcase") unless ($testcase_file);
+	
+	$testcase =~ s,^(?:.*/)?([^/]*)\.test$,$1,;
+	$self->{testname} = $testcase;
+
+	$self->die("error in test case definition") unless $self->parse_case($testcase_file);
+	
+	$self->check_features_requirement() if ($self->{test}->{features});
+}
+
+
+#
+# internal methods
+#
+
+sub add_file {
+	my ($self, $file) = @_;
+	
+	if (defined($self->{files}->{$file->{destination}})) {
+		$self->warn("duplicate specification for input file $file->{destination}");
+		return undef;
+	}
+        
+	$self->{files}->{$file->{destination}} = $file;
+	
+	return 1;
+}
+
+
+sub check_features_requirement() {
+	my ($self) = @_;
+	
+	### TODO: implement
+	
+	return 1;
+}
+
+
+sub comparator_zip {
+	my ($self, $got, $expected) = @_;
+
+	my @args = ($self->{zipcmp}, $self->{verbose} ? '-v' : '-q');
+	push @args, $self->{zipcmp_flags} if ($self->{zipcmp_flags});
+	push @args, ($expected, $got);
+        
+	my $ret = system(@args);
+	
+	return $ret == 0;
+}
+
+
+sub compare_arrays() {
+	my ($self, $a, $b, $tag) = @_;
+	
+	my $ok = 1;
+	
+	if (scalar(@$a) != scalar(@$b)) {
+		$ok = 0;
+	}
+	else {
+		for (my $i = 0; $i < scalar(@$a); $i++) {
+			if ($a->[$i] ne $b->[$i]) {
+				$ok = 0;
+				last;
+			}
+		}
+	}
+	
+	if (!$ok && $self->{verbose}) {
+		print "Unexpected $tag:\n";
+		print "--- expected\n+++ got\n";
+		my @a = map { $_ . "\n"; } @$a;
+		my @b = map { $_ . "\n"; } @$b;
+		print diff(\@a, \@b);
+	}
+	
+	return $ok;
+}
+
+
+sub compare_file() {
+	my ($self, $got, $expected) = @_;
+	
+	my $real_expected = $self->find_file($expected);
+	unless ($real_expected) {
+		$self->warn("cannot find expected result file $expected");
+		return 0;
+	}
+
+	my $ok = $self->run_comparator($got, $real_expected);
+
+	if (!defined($ok)) {
+		my $ret = system('diff', $self->{verbose} ? '-u' : '-q', $real_expected, $got);
+		$ok = ($ret == 0);
+	}
+
+	return $ok;
+}
+
+
+sub compare_files() {
+	my ($self) = @_;
+	
+	my $ok = 1;
+	
+	my $ls;
+	open $ls, "find . -type f -print |";
+	unless ($ls) {
+		# TODO: handle error
+	}
+	my @files_got = ();
+	
+	while (my $line = <$ls>) {
+		chomp $line;
+		$line =~ s,^\./,,;
+		push @files_got, $line;
+	}
+	close($ls);
+	
+	@files_got = sort @files_got;
+	my @files_should = ();
+	
+	for my $file (sort keys $self->{files}) {
+		push @files_should, $file if ($self->{files}->{$file}->{result} || $self->{files}->{$file}->{ignore});
+	}
+
+	$self->{files_got} = \@files_got;
+	$self->{files_should} = \@files_should;
+
+	unless ($self->run_hook('post_list_files')) {
+		return 0;
+	}
+	
+	$ok = $self->compare_arrays($self->{files_should}, $self->{files_got}, 'files');
+	
+	for my $file (@{$self->{files_got}}) {
+		my $file_def = $self->{files}->{$file};
+		next unless ($file_def && $file_def->{result});
+		
+		$ok &= $self->compare_file($file, $file_def->{result});
+	}
+	
+	return $ok;
+}
+
+
+sub copy_files {
+	my ($self) = @_;
+	
+	my $ok = 1;
+	
+	for my $filename (sort keys %{$self->{files}}) {
+		my $file = $self->{files}->{$filename};
+		next unless ($file->{source});
+
+		my $src = $self->find_file($file->{source});
+		unless ($src) {
+			$self->warn("cannot find input file $file->{source}");
+			$ok = 0;
+			next;
+		}
+
+		if ($file->{destination} =~ m,/,) {
+			my $dir = $file->{destination};
+			$dir =~ s,/[^/]*$,,;
+			if (! -d $dir) {
+				mkpath($dir);
+			}
+		}
+
+		my $this_ok = $self->run_copier($src, $file->{destination});
+		if (defined($this_ok)) {
+			$ok &= $this_ok;
+		}
+		else {
+			unless (copy($src, $file->{destination})) {
+				$self->warn("cannot copy $src to $file->{destination}: $!");
+				$ok = 0;
+			}
+		}
+	}
+
+	if (defined($self->{test}->{mkdir})) {
+		for my $dir_spec (@{$self->{test}->{mkdir}}) {
+			my ($mode, $dir) = @$dir_spec;
+			if (! -d $dir) {
+				unless (mkdir($dir, oct($mode))) {
+					$self->warn("cannot create directory $dir: $!");
+					$ok = 0;
+				}
+			}
+		}
+	}
+	
+	$self->die("failed to copy input files") unless ($ok);
+}
+
+
+sub die() {
+	my ($self, $msg) = @_;
+	
+	print STDERR "$0: $msg\n" if ($msg);
+	
+	$self->end_test('ERROR');
+}
+
+
+sub end_test {
+	my ($self, $status) = @_;
+	
+	my $exit_code = $EXIT_CODES{$status} // $EXIT_CODES{ERROR};
+	
+	$self->exit($exit_code);
+}
+
+
+
+sub exit() {
+	my ($self, $status) = @_;
+	### TODO: cleanup
+	
+	exit($status);
+}
+
+
+sub find_file() {
+	my ($self, $fname) = @_;
+	
+	for my $dir (('', "$self->{srcdir}/")) {
+		my $f = "$dir$fname";
+		$f = "../$f" if ($self->{in_sandbox} && $dir !~ m,^/,);
+		
+		return $f if (-f $f);
+	}
+	
+	return undef;
+}
+
+
+sub get_extension {
+	my ($self, $fname) = @_;
+
+	my $ext = $fname;
+	if ($ext =~ m/\./) {
+		$ext =~ s/.*\.//;
+	}
+	else {
+		$ext = '';
+	}
+
+	return $ext;
+}
+
+
+sub parse_args {
+	my ($self, $type, $str) = @_;
+	
+	if ($type =~ m/(\s|\.\.\.$)/) {
+		my $ellipsis = 0;
+		if ($type =~ m/(.*)\.\.\.$/) {
+			$ellipsis = 1;
+			$type = $1;
+		}
+		my @types = split /\s+/, $type;
+		my @strs = split /\s+/, $str;
+		
+		if (!$ellipsis && scalar(@types) != scalar(@strs)) {
+			$self->warn_file_line("expected " . (scalar(@types)) . " arguments, got " . (scalar(@strs)));
+			return undef;
+		}
+		
+		my $args = [];
+		
+		my $n = scalar(@types);
+		for (my $i=0; $i<scalar(@strs); $i++) {
+			my $val = $self->parse_args(($i >= $n ? $types[$n-1] : $types[$i]), $strs[$i]);
+			return undef unless (defined($val));
+			push @$args, $val;
+		}
+		
+		return $args;
+	}
+	else {
+		if ($type eq 'string') {
+			return $str;
+		}
+		elsif ($type eq 'int') {
+			if ($str !~ m/^\d+$/) {
+				$self->warn_file_line("illegal int [$str]");
+				return undef;
+			}
+			return $str+0;
+		}
+		elsif ($type eq 'char') {
+			if ($str !~ m/^.$/) {
+				$self->warn_file_line("illegal char [$str]");
+				return undef;
+			}
+			return $str;
+		}
+		else {
+			$self->warn_file_line("unknown type $type");
+			return undef;
+		}
+	}
+}
+
+
+sub parse_case() {
+	my ($self, $fname) = @_;
+	
+	my $ok = 1;
+	
+	open TST, "< $fname" or $self->die("cannot open test case $fname: $!");
+	
+	$self->{testcase_fname} = $fname;
+	
+	my %test = ();
+	
+	while (my $line = <TST>) {
+		chomp $line;
+		
+		next if ($line =~ m/^\#/);
+		
+		unless ($line =~ m/(\S*)(?:\s(.*))?/) {
+			$self->warn_file_line("cannot parse line $line");
+			$ok = 0;
+			next;
+		}
+		my ($cmd, $argstring) = ($1, $2//"");
+            
+		my $def = $self->{directives}->{$cmd};
+		
+		unless ($def) {
+			$self->warn_file_line("unknown directive $cmd in test file");
+			$ok = 0;
+			next;
+		}
+		
+		my $args = $self->parse_args($def->{type}, $argstring);
+            
+		next unless (defined($args));
+		
+		if ($def->{once}) {
+			if (defined($test{$cmd})) {
+				$self->warn_file_line("directive $cmd appeared twice in test file");
+			}
+			$test{$cmd} = $args;
+		}
+		else {
+			$test{$cmd} = [] unless (defined($test{$cmd}));
+			push @{$test{$cmd}}, $args;
+		}
+	}
+
+	close TST;
+	
+	return undef unless ($ok);
+	
+	for my $cmd (sort keys %test) {
+		if ($self->{directives}->{$cmd}->{required} && !defined($test{$cmd})) {
+			$self->warn_file("required directive $cmd missing in test file");
+			$ok = 0;
+		}
+	}
+	
+	return undef unless ($ok);
+
+	if (defined($test{'stderr-replace'}) && defined($test{stderr})) {
+	    $test{stderr} = [ map { $self->stderr_rewrite($test{'stderr-replace'}, $_); } @{$test{stderr}} ];
+	}
+
+	if (!defined($test{program})) {
+		$test{program} = $self->{default_program};
+	}
+
+	$self->{test} = \%test;
+
+	$self->run_hook('mangle_program');
+	
+	if (!$self->parse_postprocess_files()) {
+		return 0;
+	}
+
+	return $self->run_hook('post_parse');
+}
+
+
+sub parse_postprocess_files {
+	my ($self) = @_;
+	
+	$self->{files} = {};
+	
+	my $ok = 1;
+	
+	for my $file (@{$self->{test}->{file}}) {
+		$ok = 0 unless ($self->add_file({ source => $file->[1], destination => $file->[0], result => $file->[2] }));
+	}
+	
+	for my $file (@{$self->{test}->{'file-del'}}) {
+		$ok = 0 unless ($self->add_file({ source => $file->[1], destination => $file->[0], result => undef }));
+	}
+	
+	for my $file (@{$self->{test}->{'file-new'}}) {
+		$ok = 0 unless ($self->add_file({ source => undef, destination => $file->[0], result => $file->[1] }));
+	}
+	
+	return $ok;
+}
+
+
+sub print_test_result {
+	my ($self, $tag, $result, $reason) = @_;
+
+	if ($self->{verbose}) {
+		print "$self->{testname}";
+		print " ($tag)" if ($tag);
+		print " -- $result";
+		print ": $reason" if ($reason);
+		print "\n";
+	}
+}
+
+
+sub run_comparator {
+	my ($self, $got, $expected) = @_;
+
+	return $self->run_file_proc('compare_by_type', $got, $expected);
+}
+
+
+sub run_copier {
+	my ($self, $src, $dest) = @_;
+
+	return $self->run_file_proc('copy_by_type', $src, $dest);
+}
+
+
+sub run_file_proc {
+	my ($self, $proc, $got, $expected) = @_;
+
+	my $ext = ($self->get_extension($got)) . '/' . ($self->get_extension($expected));
+
+	if (defined($self->{$proc}->{$ext})) {
+		for my $sub (@{$self->{$proc}->{$ext}}) {
+			my $ret = $sub->($self, $got, $expected);
+			return $ret if (defined($ret));
+		}
+	}
+
+	return undef;
+}
+
+
+sub run_hook {
+	my ($self, $hook) = @_;
+	
+	my $ok = 1;
+	
+	if (defined($self->{hooks}->{$hook})) {
+		for my $sub (@{$self->{hooks}->{$hook}}) {
+			unless ($sub->($self, $hook)) {
+				$self->warn("hook $hook failed");
+				$ok = 0;
+			}
+		}
+	}
+	
+	return $ok;
+}
+
+
+sub run_program {
+	my ($self) = @_;
+	
+	my ($stdin, $stdout, $stderr);
+	$stderr = gensym;
+	
+	my $cmd = '../' . $self->{test}->{program} . " " . (join ' ', @{$self->{test}->{args}});
+	
+	### TODO: catch errors?
+	
+	my $pid = open3($stdin, $stdout, $stderr, $cmd);
+	
+	$self->{stdout} = [];
+	$self->{stderr} = [];
+        
+        if ($self->{test}->{pipein}) {
+                my $fh;
+                open($fh, "$self->{test}->{pipein} |");
+                if (!defined($fh)) {
+                        $self->die("cannot run pipein command [$self->{test}->{pipein}: $!");
+                }
+                while (my $line = <$fh>) {
+                        print $stdin $line;
+                }
+                close($fh);
+                close($stdin);
+        }
+	
+	while (my $line = <$stdout>) {
+		chomp $line;
+		push @{$self->{stdout}}, $line;
+	}
+	my $prg = $self->{test}->{program};
+	$prg =~ s,.*/,,;
+	while (my $line = <$stderr>) {
+		chomp $line;
+
+		$line =~ s/^[^: ]*$prg: //;
+		if (defined($self->{test}->{'stderr-replace'})) {
+		    $line = $self->stderr_rewrite($self->{test}->{'stderr-replace'}, $line);
+		}
+		push @{$self->{stderr}}, $line;
+	}
+	
+	waitpid($pid, 0);
+	
+	$self->{exit_status} = $? >> 8;
+}
+
+
+sub sandbox_create {
+	my ($self, $tag) = @_;
+	
+	$tag = ($tag ? "-$tag" : "");
+	$self->{sandbox_dir} = "sandbox-$self->{testname}$tag.d$$";
+	
+	$self->die("sandbox $self->{sandbox_dir} already exists") if (-e $self->{sandbox_dir});
+	
+	mkdir($self->{sandbox_dir}) or $self->die("cannot create sandbox $self->{sandbox_dir}: $!");
+	
+	return 1;
+}
+
+
+sub sandbox_enter {
+	my ($self) = @_;
+	
+	$self->die("internal error: cannot enter sandbox before creating it") unless (defined($self->{sandbox_dir}));
+
+	return if ($self->{in_sandbox});
+
+	chdir($self->{sandbox_dir}) or $self->die("cant cd into sandbox $self->{sandbox_dir}: $!");
+	
+	$self->{in_sandbox} = 1;
+}
+
+
+sub sandbox_leave {
+	my ($self) = @_;
+	
+	return if (!$self->{in_sandbox});
+	
+	chdir('..') or $self->die("cannot leave sandbox: $!");
+	
+	$self->{in_sandbox} = 0;
+}
+
+
+sub sandbox_remove {
+	my ($self) = @_;
+
+	my $ok = 1;
+	unless (system('chmod', '-R', 'u+rwx', $self->{sandbox_dir}) == 0) {
+		$self->warn("can't ensure that sandbox is writable: $!");
+	}
+	unless (system('rm', '-rf', $self->{sandbox_dir}) == 0) {
+		$self->warn("can't remove sandbox: $!");
+		$ok = 0;
+	}
+	
+	return $ok;
+}
+
+
+sub touch_files {
+	my ($self) = @_;
+	
+	my $ok = 1;
+	
+	if (defined($self->{test}->{touch})) {
+		for my $args (@{$self->{test}->{touch}}) {
+			my ($mtime, $fname) = @$args;
+			
+			if (!-f $fname) {
+				my $fh;
+				unless (open($fh, "> $fname") and close($fh)) {
+					# TODO: error message
+					$ok = 0;
+					next;
+				}
+			}
+			unless (utime($mtime, $mtime, $fname) == 1) {
+				# TODO: error message
+				$ok = 0;
+			}
+		}
+	}
+	
+	return $ok;
+}
+
+
+sub warn {
+	my ($self, $msg) = @_;
+	
+	print STDERR "$0: $msg\n";
+}
+
+
+sub warn_file {
+	my ($self, $msg) = @_;
+	
+	$self->warn("$self->{testcase_fname}: $msg");
+}
+
+
+sub warn_file_line {
+	my ($self, $msg) = @_;
+	
+	$self->warn("$self->{testcase_fname}:$.: $msg");
+}
+
+sub stderr_rewrite {
+    my ($self, $pattern, $line) = @_;
+    for my $repl (@{$pattern}) {
+	$line =~ s/$repl->[0]/$repl->[1]/;
+    }
+    return $line;
+}
+
+1;
diff --git a/regress/runtest.in b/regress/runtest.in
new file mode 100644
index 0000000..bab2623
--- /dev/null
+++ b/regress/runtest.in
@@ -0,0 +1,43 @@
+#!/usr/bin/env perl
+
+#  runtest -- run regression tests
+#  Copyright (C) 2002-2014 Dieter Baron and Thomas Klausner
+#
+#  This file is part of ckmame, a program to check rom sets for MAME.
+#  The authors can be contacted at <ckmame@nih.at>
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions
+#  are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in
+#     the documentation and/or other materials provided with the
+#     distribution.
+#  3. The names of the authors may not be used to endorse or promote
+#     products derived from this software without specific prior
+#     written permission.
+# 
+#  THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS
+#  OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+#  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
+#  DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+#  DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
+#  GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+#  IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+#  OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
+#  IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+use strict;
+
+BEGIN { push @INC, '@abs_srcdir@'; }
+
+use NiHTest;
+
+my $test = NiHTest::new({ default_program => 'modify', srcdir => '@srcdir@' });
+
+$test->run(@ARGV);