overhauling stats: move all stats from SongManager to ProfileManager

This commit is contained in:
Chris Danford
2003-12-07 07:09:13 +00:00
parent ed1b541a23
commit bf98d8248a
14 changed files with 778 additions and 769 deletions
+3 -691
View File
@@ -28,22 +28,14 @@
#include "ThemeManager.h"
#include "GameManager.h"
#include "RageFile.h"
#include "ProductInfo.h"
#include "RageTextureManager.h"
#include "Banner.h"
#include "ProfileManager.h"
SongManager* SONGMAN = NULL; // global and accessable from anywhere in our program
#define SM_300_STATISTICS_FILE BASE_PATH "statistics.ini"
#define SONGS_DIR BASE_PATH "Songs" SLASH
#define COURSES_DIR BASE_PATH "Courses" SLASH
#define STATS_PATH BASE_PATH "stats.html"
const CString CATEGORY_RANKING_FILE = BASE_PATH "Data" SLASH "CategoryRanking.dat";
const CString MACHINE_STEPS_MEM_CARD_DATA = BASE_PATH "Data" SLASH "MachineStepsMemCardData.dat";
const CString MACHINE_COURSE_MEM_CARD_DATA = BASE_PATH "Data" SLASH "MachineCourseMemCardData.dat";
const int CATEGORY_RANKING_VERSION = 3;
const int STEPS_MEM_CARD_DATA_VERSION = 7;
const int COURSE_MEM_CARD_DATA_VERSION = 5;
#define NUM_GROUP_COLORS THEME->GetMetricI("SongManager","NumGroupColors")
@@ -79,7 +71,6 @@ SongManager::SongManager( LoadingWindow *ld )
InitSongsFromDisk( ld );
InitCoursesFromDisk( ld );
InitAutogenCourses();
InitMachineScoresFromDisk();
} catch(...) {
SONGMAN = NULL;
@@ -92,36 +83,14 @@ SongManager::SongManager( LoadingWindow *ld )
SongManager::~SongManager()
{
SaveMachineScoresToDisk();
WriteStatsWebPage();
FreeSongs();
}
void SongManager::CategoryData::AddHighScore( HighScore hs, int &iIndexOut )
{
int i;
for( i=0; i<(int)vHighScores.size(); i++ )
{
if( hs >= vHighScores[i] )
break;
}
if( i < NUM_RANKING_LINES )
{
vHighScores.insert( vHighScores.begin()+i, hs );
iIndexOut = i;
if( int(vHighScores.size()) > NUM_RANKING_LINES )
vHighScores.erase( vHighScores.begin()+NUM_RANKING_LINES, vHighScores.end() );
}
}
void SongManager::Reload()
{
FlushDirCache();
SaveMachineScoresToDisk();
PROFILEMAN->SaveMachineScoresToDisk();
FreeSongs();
FreeCourses();
@@ -131,15 +100,7 @@ void SongManager::Reload()
InitSongsFromDisk(NULL);
InitCoursesFromDisk(NULL);
InitAutogenCourses();
InitMachineScoresFromDisk();
}
void SongManager::SaveMachineScoresToDisk()
{
SaveCategoryRankingsToFile( CATEGORY_RANKING_FILE );
SaveStepsMemCardDataToFile( MACHINE_STEPS_MEM_CARD_DATA, MEMORY_CARD_MACHINE );
SaveCourseMemCardDataToFile( MACHINE_COURSE_MEM_CARD_DATA, MEMORY_CARD_MACHINE );
PROFILEMAN->InitMachineScoresFromDisk();
}
void SongManager::InitSongsFromDisk( LoadingWindow *ld )
@@ -335,490 +296,6 @@ void SongManager::FreeSongs()
m_sGroupBannerPaths.clear();
}
//
// Helper function for reading/writing scores
//
bool FileRead(RageFile& f, CString& sOut)
{
if (f.AtEOF())
return false;
sOut = f.GetLine();
return true;
}
bool FileRead(RageFile& f, int& iOut)
{
CString s;
if (!FileRead(f, s))
return false;
iOut = atoi(s);
return true;
}
bool FileRead(RageFile& f, unsigned& uOut)
{
CString s;
if (!FileRead(f, s))
return false;
uOut = atoi(s);
return true;
}
bool FileRead(RageFile& f, float& fOut)
{
CString s;
if (!FileRead(f, s))
return false;
fOut = (float)atof(s);
return true;
}
void FileWrite(RageFile& f, const CString& sWrite)
{
f.PutLine( sWrite );
}
void FileWrite(RageFile& f, int iWrite)
{
f.PutLine( ssprintf("%d", iWrite) );
}
void FileWrite(RageFile& f, size_t uWrite)
{
f.PutLine( ssprintf("%lu", uWrite) );
}
void FileWrite(RageFile& f, float fWrite)
{
f.PutLine( ssprintf("%f", fWrite) );
}
#define WARN_AND_RETURN { LOG->Warn("Error parsing file '%s' at %s:%d",fn.c_str(),__FILE__,__LINE__); return; }
void SongManager::ReadStepsMemCardDataFromFile( CString fn, int mc )
{
RageFile f(fn);
if (!f.IsOpen() || f.GetError() != 0)
return; /* don't warn if it just doesn't exist */
int version;
if( !FileRead(f, version) )
WARN_AND_RETURN;
if( version != STEPS_MEM_CARD_DATA_VERSION )
WARN_AND_RETURN;
int iNumSongs;
if( !FileRead(f, iNumSongs) )
WARN_AND_RETURN;
for( int s=0; s<iNumSongs; s++ )
{
CString sSongDir;
if( !FileRead(f, sSongDir) )
WARN_AND_RETURN;
Song* pSong = this->GetSongFromDir( sSongDir );
int iNumNotes;
if( !FileRead(f, iNumNotes) )
WARN_AND_RETURN;
for( int n=0; n<iNumNotes; n++ )
{
StepsType nt;
if( !FileRead(f, (int&)nt) )
WARN_AND_RETURN;
Difficulty dc;
if( !FileRead(f, (int&)dc) )
WARN_AND_RETURN;
CString sDescription;
if( !FileRead(f, sDescription) )
WARN_AND_RETURN;
// Even if pSong or pNotes is null, we still have to skip over that data.
Steps* pNotes = NULL;
if( pSong )
{
if( dc==DIFFICULTY_INVALID )
pNotes = pSong->GetStepsByDescription( nt, sDescription );
else
pNotes = pSong->GetStepsByDifficulty( nt, dc );
}
int iNumTimesPlayed;
if( !FileRead(f, iNumTimesPlayed) )
WARN_AND_RETURN;
if( pNotes )
pNotes->m_MemCardDatas[mc].iNumTimesPlayed = iNumTimesPlayed;
if( pNotes )
pNotes->m_MemCardDatas[mc].vHighScores.resize(NUM_RANKING_LINES);
for( int l=0; l<NUM_RANKING_LINES; l++ )
{
CString sName;
if( !FileRead(f, sName) )
WARN_AND_RETURN;
Grade grade;
if( !FileRead(f, (int&)grade) )
WARN_AND_RETURN;
CLAMP( grade, (Grade)0, (Grade)(NUM_GRADES-1) );
int iScore;
if( !FileRead(f, iScore) )
WARN_AND_RETURN;
float fPercentDP;
if( !FileRead(f, fPercentDP) )
WARN_AND_RETURN;
if( pNotes )
{
pNotes->m_MemCardDatas[mc].vHighScores[l].sName = sName;
pNotes->m_MemCardDatas[mc].vHighScores[l].grade = grade;
pNotes->m_MemCardDatas[mc].vHighScores[l].iScore = iScore;
pNotes->m_MemCardDatas[mc].vHighScores[l].fPercentDP = fPercentDP;
}
}
}
}
}
void SongManager::ReadCategoryRankingsFromFile( CString fn )
{
RageFile f(fn);
if (!f.IsOpen() || f.GetError() != 0)
return; /* don't warn if it just doesn't exist */
int version;
if( !FileRead(f, version) )
WARN_AND_RETURN;
if( version != CATEGORY_RANKING_VERSION )
WARN_AND_RETURN;
for( int st=0; st<NUM_STEPS_TYPES; st++ )
{
for( int rc=0; rc<NUM_RANKING_CATEGORIES; rc++ )
{
m_CategoryDatas[st][rc].vHighScores.resize(NUM_RANKING_LINES);
for( int l=0; l<NUM_RANKING_LINES; l++ )
{
CString sName;
if( !FileRead(f, sName) )
WARN_AND_RETURN;
int iScore;
if( !FileRead(f, iScore) )
WARN_AND_RETURN;
m_CategoryDatas[st][rc].vHighScores[l].sName = sName;
m_CategoryDatas[st][rc].vHighScores[l].iScore = iScore;
}
}
}
}
void SongManager::ReadCourseMemCardDataFromFile( CString fn, int mc )
{
RageFile f(fn);
if (!f.IsOpen() || f.GetError() != 0)
return; /* don't warn if it just doesn't exist */
int version;
if( !FileRead(f, version) )
WARN_AND_RETURN;
if( version != COURSE_MEM_CARD_DATA_VERSION )
WARN_AND_RETURN;
int iNumCourses;
if( !FileRead(f, iNumCourses) )
WARN_AND_RETURN;
for( int c=0; c<iNumCourses; c++ )
{
CString sPath;
if( !FileRead(f, sPath) )
WARN_AND_RETURN;
Course* pCourse = GetCourseFromPath( sPath );
if( pCourse == NULL )
pCourse = GetCourseFromName( sPath );
// even if we don't find the Course*, we still have to read past the input
int NumStepsPlayed = 0;
if( !FileRead(f, NumStepsPlayed) )
WARN_AND_RETURN;
while( NumStepsPlayed-- )
{
int st;
if( !FileRead(f, st) )
WARN_AND_RETURN;
int iNumTimesPlayed;
if( !FileRead(f, iNumTimesPlayed) )
WARN_AND_RETURN;
if( pCourse )
pCourse->m_MemCardDatas[st][mc].iNumTimesPlayed = iNumTimesPlayed;
if( pCourse )
pCourse->m_MemCardDatas[st][mc].vHighScores.resize(NUM_RANKING_LINES);
for( int l=0; l<NUM_RANKING_LINES; l++ )
{
CString sName;
if( !FileRead(f, sName) )
WARN_AND_RETURN;
int iScore;
if( !FileRead(f, iScore) )
WARN_AND_RETURN;
float fSurviveTime;
if( !FileRead(f, fSurviveTime) )
WARN_AND_RETURN;
if( pCourse && st < NUM_STEPS_TYPES )
{
pCourse->m_MemCardDatas[st][mc].vHighScores[l].sName = sName;
pCourse->m_MemCardDatas[st][mc].vHighScores[l].iScore = iScore;
pCourse->m_MemCardDatas[st][mc].vHighScores[l].fSurviveTime = fSurviveTime;
}
}
}
}
}
void SongManager::InitMachineScoresFromDisk()
{
// read old style notes scores
ReadSM300NoteScores();
// category ranking
ReadCategoryRankingsFromFile( CATEGORY_RANKING_FILE );
ReadCourseMemCardDataFromFile( MACHINE_COURSE_MEM_CARD_DATA, MEMORY_CARD_MACHINE );
ReadStepsMemCardDataFromFile( MACHINE_STEPS_MEM_CARD_DATA, MEMORY_CARD_MACHINE );
}
void SongManager::ReadSM300NoteScores()
{
IniFile ini;
ini.SetPath( SM_300_STATISTICS_FILE );
if( !ini.ReadFile() ) {
LOG->Trace( "WARNING: Could not read SM 3.0 final statistics '%s'.", SM_300_STATISTICS_FILE );
return; // load nothing
}
// load song statistics
const IniFile::key* pKey = ini.GetKey( "Statistics" );
if( pKey )
{
for( IniFile::key::const_iterator iter = pKey->begin();
iter != pKey->end();
iter++ )
{
CString name = iter->first;
CString value = iter->second;
// Each value has the format "SongName::StepsType::StepsDescription=TimesPlayed::TopGrade::TopScore::MaxCombo".
char szSongDir[256];
char szStepsType[256];
char szStepsDescription[256];
int iRetVal;
// Parse for Song name and Notes name
iRetVal = sscanf( name, "%[^:]::%[^:]::%[^:]", szSongDir, szStepsType, szStepsDescription );
if( iRetVal != 3 )
continue; // this line doesn't match what is expected
CString sSongDir = FixSlashes( szSongDir );
// Search for the corresponding Song pointer.
Song* pSong = GetSongFromDir( sSongDir );
if( pSong == NULL ) // didn't find a match
continue; // skip this entry
StepsType st = GAMEMAN->StringToNotesType( szStepsType );
Difficulty dc = StringToDifficulty( szStepsDescription );
// Search for the corresponding Notes pointer.
Steps* pNotes = pSong->GetStepsByDifficulty( st, dc );
if( pNotes == NULL ) // didn't find a match
continue; // skip this entry
// Parse the Notes statistics.
char szGradeLetters[10]; // longest possible string is "AAA"
int iMaxCombo; // throw away
pNotes->m_MemCardDatas[MEMORY_CARD_MACHINE].vHighScores.resize(1);
iRetVal = sscanf(
value,
"%d::%[^:]::%d::%d",
&pNotes->m_MemCardDatas[MEMORY_CARD_MACHINE].iNumTimesPlayed,
szGradeLetters,
&pNotes->m_MemCardDatas[MEMORY_CARD_MACHINE].vHighScores[0].iScore,
&iMaxCombo
);
if( iRetVal != 4 )
continue;
pNotes->m_MemCardDatas[MEMORY_CARD_MACHINE].vHighScores[0].grade = StringToGrade( szGradeLetters );
}
}
}
void SongManager::SaveCategoryRankingsToFile( CString fn )
{
LOG->Trace("SongManager::SaveCategoryRankingsToFile");
RageFile f(fn);
if (!f.IsOpen() || f.GetError() != 0)
return;
FileWrite( f, CATEGORY_RANKING_VERSION );
for( int st=0; st<NUM_STEPS_TYPES; st++ )
{
for( int rc=0; rc<NUM_RANKING_CATEGORIES; rc++ )
{
m_CategoryDatas[st][rc].vHighScores.resize(NUM_RANKING_LINES);
for( int l=0; l<NUM_RANKING_LINES; l++ )
{
// tricky: wipe out "name to fill in" markers
if( IsRankingToFillIn(m_CategoryDatas[st][rc].vHighScores[l].sName) )
m_CategoryDatas[st][rc].vHighScores[l].sName = "";
FileWrite( f, m_CategoryDatas[st][rc].vHighScores[l].sName );
FileWrite( f, m_CategoryDatas[st][rc].vHighScores[l].iScore );
}
}
}
}
void SongManager::SaveCourseMemCardDataToFile( CString fn, int mc )
{
LOG->Trace("SongManager::SaveCourseMemCardDataToFile");
RageFile f(fn);
if (!f.IsOpen() || f.GetError() != 0)
return;
FileWrite( f, COURSE_MEM_CARD_DATA_VERSION );
FileWrite( f, m_pCourses.size() );
for( unsigned c=0; c<m_pCourses.size(); c++ ) // foreach course
{
Course* pCourse = m_pCourses[c];
ASSERT(pCourse);
if( pCourse->m_bIsAutogen )
FileWrite( f, pCourse->m_sName );
else
FileWrite( f, pCourse->m_sPath );
int NumStepsPlayed = 0;
int st;
for( st=0; st<NUM_STEPS_TYPES; st++ )
if( pCourse->m_MemCardDatas[st][mc].iNumTimesPlayed )
++NumStepsPlayed;
FileWrite( f, NumStepsPlayed );
for( st=0; st<NUM_STEPS_TYPES; st++ )
{
if( !pCourse->m_MemCardDatas[st][mc].iNumTimesPlayed )
continue;
--NumStepsPlayed;
FileWrite( f, st );
FileWrite( f, pCourse->m_MemCardDatas[st][mc].iNumTimesPlayed );
pCourse->m_MemCardDatas[st][mc].vHighScores.resize(NUM_RANKING_LINES);
for( int l=0; l<NUM_RANKING_LINES; l++ )
{
// tricky: wipe out "name to fill in" markers
if( IsRankingToFillIn(pCourse->m_MemCardDatas[st][mc].vHighScores[l].sName) )
pCourse->m_MemCardDatas[st][mc].vHighScores[l].sName = "";
FileWrite( f, pCourse->m_MemCardDatas[st][mc].vHighScores[l].sName );
FileWrite( f, pCourse->m_MemCardDatas[st][mc].vHighScores[l].iScore );
FileWrite( f, pCourse->m_MemCardDatas[st][mc].vHighScores[l].fSurviveTime );
}
}
ASSERT( !NumStepsPlayed );
}
}
void SongManager::SaveStepsMemCardDataToFile( CString fn, int mc )
{
LOG->Trace("SongManager::SaveStepsMemCardDataToFile %s", fn.c_str());
RageFile f(fn);
if (!f.IsOpen() || f.GetError() != 0)
return;
FileWrite( f, STEPS_MEM_CARD_DATA_VERSION );
FileWrite( f, m_pSongs.size() );
for( unsigned s=0; s<m_pSongs.size(); s++ ) // foreach song
{
Song* pSong = m_pSongs[s];
ASSERT(pSong);
/* If the song has never been played, don't write anything. This keeps
* us from saving a dozen copies of each song for all autogen difficulties,
* since most people only use a couple game modes. */
vector<Steps*> vNotes;
for( unsigned i=0; i<pSong->m_apNotes.size(); ++i )
{
Steps* pNotes = pSong->m_apNotes[i];
if( !pNotes->m_MemCardDatas[mc].iNumTimesPlayed )
continue;
vNotes.push_back( pNotes );
}
FileWrite( f, pSong->GetSongDir() );
FileWrite( f, vNotes.size() );
if( vNotes.size() == 0 )
continue; // skip
for( unsigned n=0; n<vNotes.size(); n++ )
{
Steps* pNotes = vNotes[n];
ASSERT(pNotes);
FileWrite( f, pNotes->m_StepsType );
FileWrite( f, pNotes->GetDifficulty() );
FileWrite( f, pNotes->GetDescription() );
FileWrite( f, pNotes->m_MemCardDatas[mc].iNumTimesPlayed );
pNotes->m_MemCardDatas[mc].vHighScores.resize(NUM_RANKING_LINES);
for( int l=0; l<NUM_RANKING_LINES; l++ )
{
// tricky: wipe out "name to fill in" markers
if( IsRankingToFillIn(pNotes->m_MemCardDatas[mc].vHighScores[l].sName) )
pNotes->m_MemCardDatas[mc].vHighScores[l].sName = "";
FileWrite( f, pNotes->m_MemCardDatas[mc].vHighScores[l].sName );
FileWrite( f, pNotes->m_MemCardDatas[mc].vHighScores[l].grade );
FileWrite( f, pNotes->m_MemCardDatas[mc].vHighScores[l].iScore );
FileWrite( f, pNotes->m_MemCardDatas[mc].vHighScores[l].fPercentDP );
}
}
}
}
CString SongManager::GetGroupBannerPath( CString sGroupName )
{
unsigned i;
@@ -1390,168 +867,3 @@ void SongManager::UpdateRankingCourses()
}
}
static CString HTMLQuoteDoubleQuotes( CString str )
{
str.Replace( "\"", "&quot;" );
return str;
}
static bool CompareStepsPointersByTypeAndDifficulty(const Steps *pStep1, const Steps *pStep2)
{
if( pStep1->m_StepsType < pStep2->m_StepsType )
return true;
if( pStep1->m_StepsType > pStep2->m_StepsType )
return false;
return pStep1->GetDifficulty() < pStep2->GetDifficulty();
}
static void SortStepsByTypeAndDifficulty( vector<Steps*> &arraySongPointers )
{
sort( arraySongPointers.begin(), arraySongPointers.end(), CompareStepsPointersByTypeAndDifficulty );
}
static void HTMLWritePerGameHeader( RageFile &f, Game game )
{
const GameDef* pGameDef = GAMEMAN->GetGameDefForGame(game);
vector<StepsType> aStepsTypes;
GAMEMAN->GetNotesTypesForGame( game, aStepsTypes );
f.PutLine( ssprintf("<h1>%s</h1>", pGameDef->m_szName) );
f.PutLine( "<table border='1'>" );
f.Write( "<tr><td>title</td>" );
unsigned j;
for( j=0; j<aStepsTypes.size(); j++ )
{
StepsType st = aStepsTypes[j];
f.PutLine( ssprintf("<td colspan='%d'>%s</td>", NUM_DIFFICULTIES, GAMEMAN->NotesTypeToString(st).c_str()) );
}
f.PutLine( "</tr>" );
f.Write( "<tr><td>&nbsp;</td>" );
for( j=0; j<aStepsTypes.size(); j++ )
{
for( unsigned k=0; k<NUM_DIFFICULTIES; k++ )
{
Difficulty d = (Difficulty)k;
f.PutLine( ssprintf("<td>%s</td>", Capitalize(DifficultyToString(d).Left(3)).c_str()) );
}
}
f.PutLine( "</tr>" );
}
// TODO: Move this to a different file. No need to clutter SongManager.
void SongManager::WriteStatsWebPage()
{
RageFile f;
if( !f.Open( STATS_PATH, RageFile::WRITE ) )
return;
f.PutLine( "<html>" );
f.PutLine( "<head>" );
f.PutLine( "<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">" );
f.PutLine( ssprintf("<title>%s</title>", PRODUCT_NAME_VER) );
f.PutLine( "</head>" );
f.PutLine( "<body>" );
vector<Song*> vSongs = m_pSongs;
SortSongPointerArrayByGroupAndTitle( vSongs );
//
// Print song list
//
f.PutLine( "<table border='1'>" );
for( unsigned i=0; i<vSongs.size(); i++ )
{
Song* pSong = m_pSongs[i];
f.Write( "<tr>" );
/* XXX: We can't call pSong->HasBanner on every song; it'll effectively re-traverse the entire
* song directory tree checking if each banner file really exists.
*
* (Note for testing this: remember that we'll cache directories for a time; this is only slow if
* the directory cache expires before we get here.) */
//CString sImagePath = pSong->HasBanner() ? pSong->GetBannerPath() : (pSong->HasBackground() ? pSong->GetBackgroundPath() : "" );
CString sImagePath = pSong->GetBannerPath();
if( sImagePath.empty() )
f.Write( "<td> </td>" );
else
f.Write( ssprintf("<td><img src=\"%s\" width='120'></td>", HTMLQuoteDoubleQuotes(sImagePath).c_str()) );
f.Write( ssprintf("<td>%s<br>", pSong->GetTranslitMainTitle().c_str()) );
f.Write( ssprintf("<font size='-1'>%s</font><br>", pSong->GetTranslitSubTitle().c_str()) );
f.Write( ssprintf("<font size='-1'><i>%s</i></font></td>", pSong->GetTranslitArtist().c_str()) );
f.PutLine( "</tr>" );
}
f.PutLine( "</table>\n<br>" );
//
// Print steps tables
//
for( int g=0; g<NUM_GAMES; g++ )
{
Game game = (Game)g;
vector<StepsType> aStepsTypes;
GAMEMAN->GetNotesTypesForGame( game, aStepsTypes );
bool WroteHeader = false;
for( unsigned i=0; i<vSongs.size(); i++ )
{
/* Get the steps for this game type. */
Song* pSong = m_pSongs[i];
vector<Steps*> Steps;
unsigned j;
for( j=0; j < aStepsTypes.size(); j++ )
pSong->GetSteps( Steps, (StepsType) aStepsTypes[j], DIFFICULTY_INVALID, -1, -1, "", false );
/* Don't write anything for songs that have no steps at all for this
* game. Otherwise, we'll write pages and pages of empty fields for
* all of the less-used game types. */
if( Steps.size() == 0 )
continue; // skip
/* We have some steps for this game. Make sure we've written the game header. */
if( !WroteHeader )
{
HTMLWritePerGameHeader( f, game );
WroteHeader = true;
}
f.PutLine( "<tr>" );
f.Write( ssprintf("<td>%s</td>", pSong->GetTranslitMainTitle().c_str()) );
SortStepsByTypeAndDifficulty( Steps );
unsigned CurSteps = 0;
for( j=0; j<aStepsTypes.size(); j++ )
{
for( int k=0; k<NUM_DIFFICULTIES; k++ )
{
if( CurSteps < Steps.size() &&
Steps[CurSteps]->m_StepsType == aStepsTypes[j] &&
Steps[CurSteps]->GetDifficulty() == k )
{
f.PutLine( ssprintf("<td>%d</td>", Steps[CurSteps]->GetMeter()) );
++CurSteps;
}
else
f.PutLine( "<td>&nbsp;</td>" );
}
}
f.Write( "</tr>" );
}
if( WroteHeader )
f.PutLine( "</table>\n<br>" ); // footer
}
f.PutLine( "</body>" );
f.PutLine( "</html>" );
}